wxt-dev / wxt-dev/wxt

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

Open
#2,609 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
10.5k
Forks
564
PR merge metrics
No merged PRs in 30d

Description

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:

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:

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:

	} 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

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the MV3 reload path in dist/virtual/background-entrypoint.mjs, including reloadManifestContentScriptMv3, reloadContentScript, and the wxt:reload-content-script listener. Reproduce with an MV3 project in wxt dev where several content-script entrypoints share source, then run the Playwright suite that records service-worker unhandled rejections. Done means concurrent reloads no longer produce duplicate-ID failures or unhandled rejections.

Written by the indexing model from the issue text.

Assessment

Tech stack
playwright, typescript
Domain
devtools, tooling
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
72/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.