Dynamic Context Menu Generation Based on Page Content
Generate context menus from live DOM state in MV3: content script DOM scan, message passing to the service worker, per-tab menu scoping, and stale-menu cleanup.
Generate context menus from live DOM state in MV3: content script DOM scan, message passing to the service worker, per-tab menu scoping, and stale-menu cleanup.
Your right-click menu shows the same stale items regardless of what is on the page, or it updates correctly on one tab but bleeds into another. You registered items in the service worker on install, and no amount of calling contextMenus.update() from the background reflects the live DOM state. This is the central constraint of dynamic context menu generation in Manifest V3, and it is covered in detail under Context Menus & Right-Click Actions.
The service worker has no access to page DOM. It runs in its own isolated execution environment and cannot call document.querySelector or read window state from any tab. Because MV3 removed persistent background pages and replaced them with non-persistent service workers, there is also no safe place to hold in-memory menu state between events — the worker can terminate at any moment. The result is that any attempt to inspect page content from the background silently fails, leaving your menus frozen at whatever was registered at install time.
The correct architecture is a three-part pipeline: a content script scans the live DOM and produces a serializable payload, that payload is sent to the service worker via chrome.runtime.sendMessage, and the service worker calls contextMenus.removeAll() then rebuilds the menu from the received data. Per-tab scoping via tabId prevents one tab’s menu state from polluting another.
The manifest needs contextMenus to create and remove menu items, scripting to inject the content script on demand, activeTab to scope injection to the user’s current tab, and storage to persist menu state across service worker restarts.
1{
2 "manifest_version": 3,
3 "name": "Dynamic Menu Extension",
4 "version": "1.0",
5 "permissions": ["contextMenus", "scripting", "activeTab", "storage"],
6 "background": {
7 "service_worker": "background.js"
8 },
9 "content_scripts": [
10 {
11 "matches": ["<all_urls>"],
12 "js": ["content.js"],
13 "run_at": "document_idle"
14 }
15 ]
16}
Execution context: Parsed by the browser at install and update time. Not a script — no globals. Chrome, Firefox, and Safari all require contextMenus to be listed explicitly; it is not implied by any other permission. Firefox additionally requires menus as an alias if you target older versions of the WebExtensions API.
The content script runs in the page’s context but in an isolated JavaScript world. It can read the live DOM freely. Scan for whatever signals your menus should react to — selected text type, presence of specific elements, data attributes — then send a plain serializable object to the background. Never send DOM nodes; they cannot cross the message boundary.
1// content.js
2function scanPageContent() {
3 const items = [];
4
5 // Detect email addresses in visible text
6 const bodyText = document.body.innerText || "";
7 const emailMatch = bodyText.match(
8 /[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}/
9 );
10 if (emailMatch) {
11 items.push({
12 id: "copy_email",
13 title: `Copy email: ${emailMatch[0]}`,
14 contexts: ["selection", "page"],
15 data: emailMatch[0]
16 });
17 }
18
19 // Detect exportable data tables
20 const tables = document.querySelectorAll("table[data-exportable]");
21 tables.forEach((table, i) => {
22 items.push({
23 id: `export_table_${i}`,
24 title: table.dataset.label || `Export table ${i + 1}`,
25 contexts: ["page"],
26 data: table.id || `table_${i}`
27 });
28 });
29
30 // Detect images that can be reverse-searched
31 const images = document.querySelectorAll("img[src]");
32 if (images.length > 0) {
33 items.push({
34 id: "reverse_image",
35 title: "Reverse image search",
36 contexts: ["image"],
37 data: null
38 });
39 }
40
41 return items;
42}
43
44// Send scan results to the service worker
45chrome.runtime.sendMessage({
46 type: "UPDATE_MENUS",
47 payload: scanPageContent()
48});
Execution context: Runs in the tab’s renderer process, isolated JavaScript world. Has access to document, window, and the full DOM. Does not have access to chrome.contextMenus — that API is unavailable in content scripts. Chrome, Firefox, and Safari all support chrome.runtime.sendMessage from content scripts. Firefox also accepts browser.runtime.sendMessage with promise-based returns.
When the message arrives, wipe the entire menu registry with removeAll() and rebuild from the payload. Cache the result in storage.session so that if the service worker restarts between the scan and the next right-click, you can restore the last known state.
1// background.js
2
3// Restore menus after a service worker cold start
4chrome.runtime.onStartup.addListener(async () => {
5 const { lastMenuState } = await chrome.storage.session.get("lastMenuState");
6 if (lastMenuState) {
7 await rebuildMenus(lastMenuState.items, lastMenuState.tabId);
8 }
9});
10
11chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
12 if (message.type !== "UPDATE_MENUS") return;
13 if (!sender.tab?.id) return;
14
15 const tabId = sender.tab.id;
16
17 // Run async work and signal completion
18 handleMenuUpdate(message.payload, tabId).then(() =>
19 sendResponse({ ok: true })
20 );
21
22 // Return true to keep the message channel open for the async response
23 return true;
24});
25
26async function handleMenuUpdate(items, tabId) {
27 await chrome.contextMenus.removeAll();
28
29 await rebuildMenus(items, tabId);
30
31 // Cache state so it survives a worker restart
32 await chrome.storage.session.set({
33 lastMenuState: { items, tabId }
34 });
35}
36
37async function rebuildMenus(items, tabId) {
38 if (!items.length) return;
39
40 // Parent item groups everything under one submenu
41 await chrome.contextMenus.create({
42 id: "dynamic_root",
43 title: "Page Actions",
44 contexts: ["all"],
45 tabId
46 });
47
48 for (const item of items) {
49 await chrome.contextMenus.create({
50 id: item.id,
51 parentId: "dynamic_root",
52 title: item.title,
53 contexts: item.contexts,
54 tabId
55 });
56 }
57}
Execution context: Runs in the service worker. Has access to all chrome.* extension APIs except DOM APIs. The return true in onMessage is required when the response is sent asynchronously — omitting it causes the message port to close before sendResponse fires. Firefox supports the same pattern but prefers returning a Promise directly from the listener instead of return true. Safari on iOS has limited storage.session support prior to Safari 17.
The tabId parameter on contextMenus.create() restricts a menu item to a single tab. Without it, items appear on every tab, and a removeAll() call from any tab wipes menus that another tab depends on. Always pass tabId from sender.tab.id when creating items in response to a content script message.
1// background.js — correct tabId usage
2await chrome.contextMenus.create({
3 id: `item_${item.id}`,
4 parentId: "dynamic_root",
5 title: item.title,
6 contexts: item.contexts,
7 tabId: tabId // scopes this item to one tab only
8});
Execution context: Service worker. The tabId field on contextMenus.create is a Chrome-specific extension to the WebExtensions spec. Firefox supports it from version 109. Safari does not support tabId scoping on context menu items as of Safari 17 — items created with tabId are silently treated as global items, so cross-tab contamination must be handled with manual ID-based removal on Safari.
Tab-scoped menu items are not automatically removed when a tab closes in all browsers. Register a tabs.onRemoved listener to explicitly call removeAll() and clear the cached state for that tab.
1// background.js
2chrome.tabs.onRemoved.addListener(async (tabId) => {
3 // Remove all menus — tab-scoped items for this tab are now orphaned
4 await chrome.contextMenus.removeAll();
5
6 // Clear the cached menu state if it belonged to the closed tab
7 const { lastMenuState } = await chrome.storage.session.get("lastMenuState");
8 if (lastMenuState?.tabId === tabId) {
9 await chrome.storage.session.remove("lastMenuState");
10 }
11});
Execution context: Service worker, event-driven. tabs.onRemoved fires reliably in Chrome, Firefox, and Safari when a tab is closed, including when a window containing the tab is closed. The removeAll() call here removes every item across all tabs — if you are managing multiple tabs simultaneously, maintain a per-tab registry in storage.session and remove only the items whose IDs belong to the closed tab.
tabId scoping on contextMenus.create. storage.session available from Chrome 102. Promise-based contextMenus calls work natively. removeAll() followed immediately by create() in the same microtask queue is safe.browser.* namespace with native promise returns. tabId scoping on context menus supported from Firefox 109. The return true pattern in onMessage works but returning a Promise is preferred. storage.session available from Firefox 115. Stricter CSP on injected scripts — avoid any eval-adjacent patterns in content scripts.tabId scoping on context menu items (as of Safari 17); treat all created items as global and manage removal manually by tracking item IDs per tab in storage.session. storage.session support arrived in Safari 17. contextMenus API available only in Safari 15.4 and later for desktop; not available on iOS Safari regardless of version.chrome://extensions with Developer mode enabled.chrome://extensions → your extension → Inspect views: service worker.tabs.onRemoved fired (add a console.log to verify) and that storage.session no longer contains the first tab’s state: run chrome.storage.session.get(null, console.log) in the service worker console.chrome://extensions, then right-click on the remaining tab. The menus should restore from storage.session via onStartup.contextMenus.removeAll() wipe menus on other tabs?removeAll() clears the entire global menu registry — it is not tab-aware. If you need to update one tab’s menus without touching another’s, track item IDs per tab in storage.session and remove only those specific IDs using contextMenus.remove(id) in a loop before recreating them.
The most common cause is that the service worker has already terminated before the message arrives. Use chrome.runtime.sendMessage with the return true pattern and wrap your listener in an explicit wake-up: inject the content script via chrome.scripting.executeScript from a user gesture handler (such as browserAction.onClicked) so the service worker is guaranteed to be running when the message is sent. See Service Worker Fundamentals for keep-alive patterns.
removeAll() each time?Yes, if you know which items have changed you can call chrome.contextMenus.update(id, properties) on existing items and only call create for new ones. This avoids the flicker caused by a full teardown. Track the current set of item IDs per tab in storage.session so you can diff incoming payloads against what is already registered.
Attach the action data you need as part of the item ID string (e.g., copy_email:user@example.com) or store a lookup table in storage.session keyed by item ID. In contextMenus.onClicked, parse the ID or retrieve the stored data, then call chrome.tabs.sendMessage(info.tab.id, { type: "EXECUTE_ACTION", data: ... }) to hand execution back to the content script.
storage.session semantics used here to cache menu state across worker restartsImplement persistent side panels and DevTools extension panels in MV3 using chrome.sidePanel and chrome.devtools.panels — per-tab control, lifecycle, and cross-browser gaps.
Build chrome.contextMenus items in MV3: register in onInstalled, handle onClicked in the service worker, create parent/child menus, and gate visibility by context type.
Register and handle keyboard shortcuts in MV3 extensions using the chrome.commands API — manifest declaration, service worker listeners, per-OS suggested keys, the 4-shortcut limit, and cross-browser rebinding via chrome://extensions/shortcuts.
Design scalable options page UIs for MV3 extensions — sidebar nav, tabbed sections, responsive grids, form components, autosave UX, and dark mode support.