#2609·wxt

Duplicate script ID: content script reload races itself in MV3 dev, rejects unhandled

Author: bompusCreated Sep 4, 2026Updated Sep 4, 2026

Describe the bug

In MV3 dev, reloadManifestContentScriptMv3 registers content scripts with a check-then-act sequence. When two wxt:reload-content-script events for the same entrypoint arrive close together, both read a registration list that does not yet contain the script, and both call registerContentScripts. The loser throws Duplicate script ID 'wxt:content-scripts/<name>.js'.

Because the WebSocket listener does not await or catch, the rejection lands as an unhandled promise rejection in the service worker.

dist/virtual/background-entrypoint.mjs (0.21.4), the check-then-act:

javascript
async function reloadManifestContentScriptMv3(contentScript) {
	const id = `wxt:${contentScript.js[0]}`;
	const registered = await browser.scripting.getRegisteredContentScripts();
	const existing = registered.find((cs) => cs.id === id);
	if (existing) {
		await browser.scripting.updateContentScripts([{ ...contentScript, id, css: contentScript.css ?? [] }]);
	} else {
		await browser.scripting.registerContentScripts([{ ...contentScript, id, css: contentScript.css ?? [] }]);
	}
	await reloadTabsForContentScript(contentScript);
}

and why nothing catches it:

javascript
ws.addWxtEventListener("wxt:reload-content-script", (event) => {
	reloadContentScript(event.detail);        // not awaited, not caught
});

function reloadContentScript(payload) {
	if (browser.runtime.getManifest().manifest_version == 2) reloadContentScriptMv2(payload);
	else reloadContentScriptMv3(payload);     // promise not returned either
}

reloadContentScript does not return the promise from its MV3 branch, so a .catch() at the listener would not help — the rejection has to be handled inside reloadManifestContentScriptMv3.

Relevant background: registrations made with scripting.registerContentScripts() persist across service worker restarts (noted in #2239), so the window where two events can both observe "not yet registered" is real rather than theoretical.

Impact

Intermittent, and normally invisible because it only prints to the service worker console. It became visible for us because our Playwright suite records service-worker unhandled rejections and fails any test that sees one. Across a 23-spec run in dev mode it fired roughly twice per run, failing whichever specs were in flight.

Reproduction

Not reliably reproducible on demand — it is a race. It shows up on an MV3 project in wxt dev where several content script entrypoints share source, so one save emits multiple reload events in quick succession. Two error entries 3 ms apart in one run is typical:

Duplicate script ID 'wxt:content-scripts/ttd.js'   2026-09-04T08:18:12.039Z
Duplicate script ID 'wxt:content-scripts/ttd.js'   2026-09-04T08:18:12.042Z

Suggested fix

Treat a lost race as the update it would have been:

javascript
	} else {
		try {
			await browser.scripting.registerContentScripts([{ ...contentScript, id, css: contentScript.css ?? [] }]);
		} catch (err) {
			if (!String(err?.message ?? err).includes("Duplicate script ID")) throw err;
			await browser.scripting.updateContentScripts([{ ...contentScript, id, css: contentScript.css ?? [] }]);
		}
	}

Having reloadContentScript return the promise from its MV3 branch would also let callers handle failures, though it would not fix this race on its own.

I am running this as a local patch against 0.21.4 and it clears the error. Happy to open a PR if the approach looks right.

Versions

  • wxt 0.21.4
  • MV3, Chromium (Microsoft Edge), Windows 11
  • Bun 1.4.0