Sharing Code Between Popup, Options and Side Panel
Structure an MV3 extension so three surfaces share one data layer and one component set — module boundaries, per-surface entry points, and what must not be shared.
Table of Contents
By the second release an extension has a popup, an options page and often a side panel, and each has its own copy of the settings reader, its own storage key constants and its own slightly different idea of what a “site entry” looks like. They drift, and the drift shows up as a setting that saves in one surface and does not appear in another. The fix is a deliberate module boundary, not discipline. This guide is part of extension popup architecture.
What the three surfaces actually have in common
They are all extension pages on the same origin, with the same chrome.* access and the same CSP. What differs is lifetime, viewport and the interactions each can complete — which means the data layer can be fully shared, the components mostly, and the layout almost not at all.
Step-by-step
1. Put every storage key behind one module
1// src/data/settings.js
2const KEY = "settings";
3export const DEFAULTS = { theme: "auto", syncHour: 7, enabledOrigins: [] };
4
5export async function read() {
6 const { [KEY]: raw } = await chrome.storage.sync.get(KEY);
7 return { ...DEFAULTS, ...(raw ?? {}) };
8}
9
10export async function patch(partial) {
11 const next = { ...(await read()), ...partial };
12 await chrome.storage.sync.set({ [KEY]: next });
13 return next;
14}
15
16export function subscribe(fn) {
17 const handler = (changes, area) => {
18 if (area === "sync" && KEY in changes) fn({ ...DEFAULTS, ...changes[KEY].newValue });
19 };
20 chrome.storage.onChanged.addListener(handler);
21 return () => chrome.storage.onChanged.removeListener(handler);
22}
Execution context: any extension page, and the service worker. The module has no DOM dependency, which is what lets the worker import it too — and that is the property that stops the worker and the UI disagreeing about the default value of a setting.
The subscribe function returning an unsubscribe is what makes the module safe in the side panel, which lives long enough to accumulate listeners.
2. Write components that take data, not selectors
A component that queries the document for its own container cannot be reused. One that is handed an element and a value can.
1// src/ui/toggle.js
2export function toggle(root, { label, checked, onChange }) {
3 root.innerHTML = "";
4 const id = `t-${Math.random().toString(36).slice(2, 8)}`;
5 const input = Object.assign(document.createElement("input"), { type: "checkbox", id, checked });
6 const text = Object.assign(document.createElement("label"), { htmlFor: id, textContent: label });
7 input.addEventListener("change", () => onChange(input.checked));
8 root.append(input, text);
9 return { set: (v) => { input.checked = v; } };
10}
Execution context: any extension page. Generating the id rather than accepting one means two instances on the same page cannot collide — the duplicate-id problem that fails an accessibility audit, checked in accessible form controls for extension settings.
3. Keep entry points thin
1// src/entries/popup.js
2import { read, patch, subscribe } from "../data/settings.js";
3import { toggle } from "../ui/toggle.js";
4
5const settings = await read();
6const t = toggle(document.querySelector("#enabled"), {
7 label: "Enabled on this site",
8 checked: settings.enabledOrigins.includes(origin),
9 onChange: (v) => patch({ enabledOrigins: nextOrigins(settings, v) }),
10});
11
12subscribe((s) => t.set(s.enabledOrigins.includes(origin)));
Execution context: the popup document. The entry point does three things — read, mount, subscribe — and contains no business logic. The same three lines appear in options.js and panel.js with a different layout around them.
4. Share the module graph, not the bundle
Each surface gets its own HTML and its own entry, and the bundler emits a shared chunk. That keeps the popup’s payload small while the options page, which can afford more, pulls in what it needs.
1// vite.config.js
2export default {
3 build: {
4 rollupOptions: {
5 input: {
6 popup: "src/entries/popup.html",
7 options: "src/entries/options.html",
8 panel: "src/entries/panel.html",
9 sw: "src/entries/service-worker.js",
10 },
11 },
12 },
13};
Execution context: the build, in Node. The service worker must be a separate input with no shared chunk that touches the DOM — a shared module importing document would throw on worker start. The configuration is expanded in bundling an MV3 extension with Vite.
5. Know what must not be shared
- Lifetime assumptions. A popup component may safely skip cleanup; a side-panel component may not, because it can live for hours.
- Focus management. The options page may take focus on load; the popup should not steal it, and the side panel must not steal it from the page.
- Sizing. A component that sets a fixed pixel width works in a popup and breaks in a resizable panel.
- Anything that opens a new surface.
chrome.tabs.createfrom the popup closes the popup; from the options page it does not. The calling surface decides.
1// Surface capability, passed in rather than detected inside the component.
2export function siteRow(root, { site, canOpenInTab, onOpen }) {
3 // …render…
4 if (canOpenInTab) root.append(openButton);
5}
Execution context: any extension page. Passing the capability keeps the component testable and stops it growing a location.pathname.includes("popup") check, which is the shape this always takes when detection is done inside.
Where shared code usually goes wrong
Three failure patterns account for most of the pain, and all three are architectural rather than accidental.
The god module. A single shared.js that grows to hold storage access, DOM helpers, formatting and the message protocol. It gets imported everywhere, including into the service worker, and the first time someone adds a document.createElement to it the worker throws on cold start with an error that names a line nobody associates with the worker. Split by dependency, not by convenience: modules that touch the DOM live in one directory, modules that do not live in another, and the worker may only import from the second.
The component that reads storage. It is tempting to have the site-row component fetch its own data — fewer parameters, less plumbing. It also means the component cannot be rendered twice without two reads, cannot be tested without a chrome mock, and re-reads on every render. Components take values; entry points do I/O.
The runtime surface check. Once one component behaves differently in the popup, a check appears — location.pathname.includes("popup") — and within two releases there are six of them in four files. Pass capability flags down from the entry point instead, as in step 5.
A quick structural test catches all three: can the data directory be imported by a Node test with no chrome global and no DOM? If not, something is in the wrong place.
1// test/data.test.js — no jsdom, no chrome mock beyond storage
2globalThis.chrome = { storage: { sync: fakeArea(), onChanged: fakeEvent() } };
3const { read, DEFAULTS } = await import("../src/data/settings.js");
4assert.deepEqual(await read(), DEFAULTS);
Execution context: Node, under your test runner. The fake needs only the surfaces the module actually touches, which is a good sign in itself — a data module that requires a large mock is doing too much.
Cross-browser variation
- Chrome / Edge: all three surfaces exist. The side panel is Chrome 114+, so the panel entry point should be built and shipped but only reachable where
chrome.sidePanelexists. - Firefox: the equivalent of the side panel is
sidebarAction, which loads an ordinary extension page — the samepanel.htmlworks with a different manifest key, as covered in generating a manifest per browser target. - Safari: popup and options only. The panel entry is simply not built into the Safari target, which is cleaner than shipping an unreachable page.
- All three: every surface shares one origin and one storage. Two surfaces open at once will both receive
storage.onChanged, which is what makessubscribesufficient for keeping them in step.
Verification
- Confirm the surfaces agree. Open the options page and the popup, change a setting in one, and watch the other update without a reload.
- Check the worker does not pull in DOM code:
1grep -n "document\.\|window\." dist/service-worker.js | head
2# (no output)
Execution context: your shell, against the built bundle. A hit here means a shared module reached the worker’s chunk and will throw on the next cold start.
- Confirm the shared chunk exists and the popup bundle is small — check the emitted file sizes in your build output.
- Open the side panel, leave it for ten minutes with the options page opening and closing, and confirm listener count is stable in DevTools Memory.
FAQ
Should the service worker import the same modules?
The data modules, yes — that is the point. The UI modules, no. Keep the two directories separate so the boundary is visible in the import path rather than in a convention.
Is one HTML file with query parameters simpler than three?
It is fewer files and worse in every other way: the manifest needs distinct URLs anyway, the bundle is the union of all three surfaces, and the layout branches at runtime. Three entries with a shared chunk is both smaller and clearer.
How do I test a shared component?
Mount it into a detached element in a test and assert on the resulting DOM. Because components take data and callbacks rather than reading storage, no chrome.* mock is needed — see testing message handlers in isolation.
Related
- Why the popup closes and how to work with it — the lifetime difference that stops layout being shared.
- Embedding options in the popup — reusing the options UI in a smaller viewport.
- Bundling an MV3 extension with Vite — emitting the shared chunk.
- Extension popup architecture — the parent guide.