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.
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.
Root Cause
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.
Step-by-Step Solution
Step 1: Declare the required permissions
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.
Step 2: Write the content script DOM scanner
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.
Step 3: Write the service worker message handler
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.
Step 4: Scope every menu item with tabId
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.
Step 5: Clean up menus when a tab closes
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.
Cross-browser variation
- Chrome / Edge: Full support for
tabIdscoping oncontextMenus.create.storage.sessionavailable from Chrome 102. Promise-basedcontextMenuscalls work natively.removeAll()followed immediately bycreate()in the same microtask queue is safe. - Firefox: Uses
browser.*namespace with native promise returns.tabIdscoping on context menus supported from Firefox 109. Thereturn truepattern inonMessageworks but returning aPromiseis preferred.storage.sessionavailable from Firefox 115. Stricter CSP on injected scripts — avoid anyeval-adjacent patterns in content scripts. - Safari: Does not support
tabIdscoping on context menu items (as of Safari 17); treat all created items as global and manage removal manually by tracking item IDs per tab instorage.session.storage.sessionsupport arrived in Safari 17.contextMenusAPI available only in Safari 15.4 and later for desktop; not available on iOS Safari regardless of version.
Verification
- Load the extension in Chrome via
chrome://extensionswith Developer mode enabled. - Open two tabs with visibly different content — one with a data table, one without.
- Right-click on the first tab. Open the DevTools Service Worker console from
chrome://extensions→ your extension → Inspect views: service worker. - You should see no errors. The menu should show “Page Actions” with child items matching what the content script found.
- Switch to the second tab and right-click. The menu should reflect that tab’s content only — no items from the first tab should appear.
- Close the first tab. In the service worker console, confirm
tabs.onRemovedfired (add aconsole.logto verify) and thatstorage.sessionno longer contains the first tab’s state: runchrome.storage.session.get(null, console.log)in the service worker console. - To confirm cold-start recovery, click “Stop” on the service worker from
chrome://extensions, then right-click on the remaining tab. The menus should restore fromstorage.sessionviaonStartup.
FAQ
Why does 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 content script sends the message but the service worker never receives it — what is wrong?
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.
Can I update menus without calling 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.
How do I pass the clicked item’s data back to the content script?
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.
Related
- Implementing Global Keyboard Shortcuts Safely — complements right-click actions with keyboard-driven alternatives that share the same service worker message pipeline
- Service Worker Fundamentals — covers the non-persistent lifecycle that makes DOM-free background scripts the core constraint of this pattern
- Chrome Storage API Sync — details
storage.sessionsemantics used here to cache menu state across worker restarts - Context Menus & Right-Click Actions — parent reference covering the full context menu API surface in MV3