Duplicate script ID: content script reload races itself in MV3 dev, rejects unhandled
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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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