From cfc4b3d0c9b700b41bd12aa59314c0ec78e01a0a Mon Sep 17 00:00:00 2001 From: fkatsuhiro Date: Sun, 9 Aug 2026 14:06:14 +0900 Subject: [PATCH 1/2] fix: await content script reloads before restarting extension --- packages/wxt-demo/src/entrypoints/content.ts | 1 + .../src/core/utils/create-file-reloader.ts | 4 +++ packages/wxt/src/utils/inject-script.ts | 10 ++++++ .../utils/internal/dev-server-websocket.ts | 2 ++ .../wxt/src/virtual/background-entrypoint.ts | 11 ++++-- .../virtual/utils/reload-content-scripts.ts | 36 +++++++++++++------ 6 files changed, 51 insertions(+), 13 deletions(-) diff --git a/packages/wxt-demo/src/entrypoints/content.ts b/packages/wxt-demo/src/entrypoints/content.ts index 1b3865ba3..6672c36a4 100644 --- a/packages/wxt-demo/src/entrypoints/content.ts +++ b/packages/wxt-demo/src/entrypoints/content.ts @@ -2,6 +2,7 @@ export default defineContentScript({ matches: ['*://*.example.com/*'], async main() { + console.log(getAppConfig()); console.log('Injecting...'); await injectScript('/unlisted.js', { keepInDom: true, diff --git a/packages/wxt/src/core/utils/create-file-reloader.ts b/packages/wxt/src/core/utils/create-file-reloader.ts index 1a731e245..0304bb980 100644 --- a/packages/wxt/src/core/utils/create-file-reloader.ts +++ b/packages/wxt/src/core/utils/create-file-reloader.ts @@ -124,6 +124,10 @@ export function createFileReloader(server: WxtDevServer) { // Perform reloads if (hasNewEntrypoints || changes.type === 'extension-reload') { + // `server.reloadExtension()` only reloads the background/manifest - + // it doesn't re-inject content scripts into tabs that are already + // open, so tell those to reload too. + reloadContentScripts(newOutput.steps, server); server.reloadExtension(); wxt.logger.success(`Reloaded extension`); } else if (changes.type === 'html-reload') { diff --git a/packages/wxt/src/utils/inject-script.ts b/packages/wxt/src/utils/inject-script.ts index 81007db06..19d500b0b 100644 --- a/packages/wxt/src/utils/inject-script.ts +++ b/packages/wxt/src/utils/inject-script.ts @@ -24,7 +24,17 @@ export async function injectScript( ): Promise { // @ts-expect-error: getURL is defined per-project, but not inside the package const url = browser.runtime.getURL(path); + + // If a previous call left this same script in the DOM (`keepInDom`), + // remove it first. Otherwise, calling `injectScript` again for the same + // path - e.g. because the content script that called it got re-run - + // would leave multiple copies of it running side by side. + document.querySelectorAll('script').forEach((el) => { + if (el.dataset.wxtInjectedPath === path) el.remove(); + }); + const script = document.createElement('script'); + script.dataset.wxtInjectedPath = path; const isManifestV2 = browser.runtime.getManifest().manifest_version === 2; diff --git a/packages/wxt/src/utils/internal/dev-server-websocket.ts b/packages/wxt/src/utils/internal/dev-server-websocket.ts index 913f6b7e2..4b5943ae2 100644 --- a/packages/wxt/src/utils/internal/dev-server-websocket.ts +++ b/packages/wxt/src/utils/internal/dev-server-websocket.ts @@ -1,3 +1,4 @@ +import type { Browser } from '@wxt-dev/browser'; import { logger } from './logger'; interface WebSocketMessage { @@ -76,5 +77,6 @@ export interface ReloadContentScriptPayload { matches: string[]; js?: string[]; css?: string[]; + world?: Browser.scripting.RegisteredContentScript['world']; }; } diff --git a/packages/wxt/src/virtual/background-entrypoint.ts b/packages/wxt/src/virtual/background-entrypoint.ts index b0206e0bb..d0b8a2c8f 100644 --- a/packages/wxt/src/virtual/background-entrypoint.ts +++ b/packages/wxt/src/virtual/background-entrypoint.ts @@ -7,13 +7,20 @@ import { keepServiceWorkerAlive } from './utils/keep-service-worker-alive'; import { reloadContentScript } from './utils/reload-content-scripts'; if (import.meta.env.COMMAND === 'serve') { + // Content script reloads for an `extension-reload` are sent as separate, + // unawaited messages just before it. Track them so `runtime.reload()` + // doesn't tear down the background (and its message handling) before + // they've had a chance to finish injecting into open tabs. + const pendingContentScriptReloads: Promise[] = []; + try { const ws = getDevServerWebSocket(); - ws.addWxtEventListener('wxt:reload-extension', () => { + ws.addWxtEventListener('wxt:reload-extension', async () => { + await Promise.allSettled(pendingContentScriptReloads); browser.runtime.reload(); }); ws.addWxtEventListener('wxt:reload-content-script', (event) => { - reloadContentScript(event.detail); + pendingContentScriptReloads.push(reloadContentScript(event.detail)); }); if (import.meta.env.MANIFEST_VERSION === 3) { diff --git a/packages/wxt/src/virtual/utils/reload-content-scripts.ts b/packages/wxt/src/virtual/utils/reload-content-scripts.ts index 5d3b4363e..ce723aa80 100644 --- a/packages/wxt/src/virtual/utils/reload-content-scripts.ts +++ b/packages/wxt/src/virtual/utils/reload-content-scripts.ts @@ -3,13 +3,13 @@ import { logger } from '../../utils/internal/logger'; import { MatchPattern } from 'wxt/utils/match-patterns'; import type { ReloadContentScriptPayload } from '../../utils/internal/dev-server-websocket'; -export function reloadContentScript(payload: ReloadContentScriptPayload) { +export function reloadContentScript( + payload: ReloadContentScriptPayload, +): Promise { const manifest = browser.runtime.getManifest(); - if (manifest.manifest_version == 2) { - void reloadContentScriptMv2(payload); - } else { - void reloadContentScriptMv3(payload); - } + return manifest.manifest_version == 2 + ? reloadContentScriptMv2(payload) + : reloadContentScriptMv3(payload); } export async function reloadContentScriptMv3({ @@ -55,7 +55,7 @@ export async function reloadManifestContentScriptMv3( ]); } - await reloadTabsForContentScript(contentScript); + await reExecuteInMatchingTabs(contentScript); } export async function reloadRuntimeContentScriptMv3( @@ -80,14 +80,24 @@ export async function reloadRuntimeContentScriptMv3( } await browser.scripting.updateContentScripts(matches); - await reloadTabsForContentScript(contentScript); + await reExecuteInMatchingTabs(contentScript); } -async function reloadTabsForContentScript(contentScript: ContentScript) { - const allTabs = await browser.tabs.query({}); +/** + * Re-runs a content script's JS files inside tabs that already have it + * injected, instead of doing a full `tabs.reload()`. A full reload works, but + * it navigates the page, which clears devtools console output - making it hard + * to tell whether the reload actually picked up the change. Running the updated + * files in-place keeps the page (and previous logs) around, so the new console + * output shows up right next to the old output. + */ +async function reExecuteInMatchingTabs(contentScript: ContentScript) { + if (!contentScript.js?.length) return; + const matchPatterns = contentScript.matches.map( (match) => new MatchPattern(match), ); + const allTabs = await browser.tabs.query({}); const matchingTabs = allTabs.filter((tab) => { const url = tab.url; if (!url) return false; @@ -96,7 +106,11 @@ async function reloadTabsForContentScript(contentScript: ContentScript) { await Promise.all( matchingTabs.map(async (tab) => { try { - await browser.tabs.reload(tab.id!); + await browser.scripting.executeScript({ + target: { tabId: tab.id! }, + files: contentScript.js!, + world: contentScript.world, + }); } catch (err) { logger.warn('Failed to reload tab:', err); } From 91949bccee4fb68e6eb304c8b1919356c9ac6b5d Mon Sep 17 00:00:00 2001 From: fkatsuhiro Date: Sun, 9 Aug 2026 18:28:50 +0900 Subject: [PATCH 2/2] refacter: functions and commentout --- .../src/core/utils/create-file-reloader.ts | 5 +-- packages/wxt/src/utils/inject-script.ts | 10 ----- .../wxt/src/virtual/background-entrypoint.ts | 16 ++++---- .../virtual/utils/reload-content-scripts.ts | 37 +++++++++---------- 4 files changed, 26 insertions(+), 42 deletions(-) diff --git a/packages/wxt/src/core/utils/create-file-reloader.ts b/packages/wxt/src/core/utils/create-file-reloader.ts index 0304bb980..b1d7c242e 100644 --- a/packages/wxt/src/core/utils/create-file-reloader.ts +++ b/packages/wxt/src/core/utils/create-file-reloader.ts @@ -124,9 +124,8 @@ export function createFileReloader(server: WxtDevServer) { // Perform reloads if (hasNewEntrypoints || changes.type === 'extension-reload') { - // `server.reloadExtension()` only reloads the background/manifest - - // it doesn't re-inject content scripts into tabs that are already - // open, so tell those to reload too. + // `server.reloadExtension()` only reloads the background/manifest + // reload content scripts into tabs which are already open. reloadContentScripts(newOutput.steps, server); server.reloadExtension(); wxt.logger.success(`Reloaded extension`); diff --git a/packages/wxt/src/utils/inject-script.ts b/packages/wxt/src/utils/inject-script.ts index 19d500b0b..81007db06 100644 --- a/packages/wxt/src/utils/inject-script.ts +++ b/packages/wxt/src/utils/inject-script.ts @@ -24,17 +24,7 @@ export async function injectScript( ): Promise { // @ts-expect-error: getURL is defined per-project, but not inside the package const url = browser.runtime.getURL(path); - - // If a previous call left this same script in the DOM (`keepInDom`), - // remove it first. Otherwise, calling `injectScript` again for the same - // path - e.g. because the content script that called it got re-run - - // would leave multiple copies of it running side by side. - document.querySelectorAll('script').forEach((el) => { - if (el.dataset.wxtInjectedPath === path) el.remove(); - }); - const script = document.createElement('script'); - script.dataset.wxtInjectedPath = path; const isManifestV2 = browser.runtime.getManifest().manifest_version === 2; diff --git a/packages/wxt/src/virtual/background-entrypoint.ts b/packages/wxt/src/virtual/background-entrypoint.ts index d0b8a2c8f..41e13ee8f 100644 --- a/packages/wxt/src/virtual/background-entrypoint.ts +++ b/packages/wxt/src/virtual/background-entrypoint.ts @@ -4,23 +4,21 @@ import { getDevServerWebSocket } from '../utils/internal/dev-server-websocket'; import { logger } from '../utils/internal/logger'; import { browser } from 'wxt/browser'; import { keepServiceWorkerAlive } from './utils/keep-service-worker-alive'; -import { reloadContentScript } from './utils/reload-content-scripts'; +import { + reloadContentScript, + waitForPendingContentScriptReloads, +} from './utils/reload-content-scripts'; if (import.meta.env.COMMAND === 'serve') { - // Content script reloads for an `extension-reload` are sent as separate, - // unawaited messages just before it. Track them so `runtime.reload()` - // doesn't tear down the background (and its message handling) before - // they've had a chance to finish injecting into open tabs. - const pendingContentScriptReloads: Promise[] = []; - try { const ws = getDevServerWebSocket(); ws.addWxtEventListener('wxt:reload-extension', async () => { - await Promise.allSettled(pendingContentScriptReloads); + // Wait for content script reloads to finish before killing the background. + await waitForPendingContentScriptReloads(); browser.runtime.reload(); }); ws.addWxtEventListener('wxt:reload-content-script', (event) => { - pendingContentScriptReloads.push(reloadContentScript(event.detail)); + reloadContentScript(event.detail); }); if (import.meta.env.MANIFEST_VERSION === 3) { diff --git a/packages/wxt/src/virtual/utils/reload-content-scripts.ts b/packages/wxt/src/virtual/utils/reload-content-scripts.ts index ce723aa80..fc2de21e5 100644 --- a/packages/wxt/src/virtual/utils/reload-content-scripts.ts +++ b/packages/wxt/src/virtual/utils/reload-content-scripts.ts @@ -3,13 +3,20 @@ import { logger } from '../../utils/internal/logger'; import { MatchPattern } from 'wxt/utils/match-patterns'; import type { ReloadContentScriptPayload } from '../../utils/internal/dev-server-websocket'; -export function reloadContentScript( - payload: ReloadContentScriptPayload, -): Promise { +const pendingReloads = new Set>(); + +export function reloadContentScript(payload: ReloadContentScriptPayload): void { const manifest = browser.runtime.getManifest(); - return manifest.manifest_version == 2 - ? reloadContentScriptMv2(payload) - : reloadContentScriptMv3(payload); + const promise = + manifest.manifest_version == 2 + ? reloadContentScriptMv2(payload) + : reloadContentScriptMv3(payload); + pendingReloads.add(promise); + promise.finally(() => pendingReloads.delete(promise)); +} + +export async function waitForPendingContentScriptReloads(): Promise { + await Promise.allSettled(pendingReloads); } export async function reloadContentScriptMv3({ @@ -55,7 +62,7 @@ export async function reloadManifestContentScriptMv3( ]); } - await reExecuteInMatchingTabs(contentScript); + await reloadTabsForContentScript(contentScript); } export async function reloadRuntimeContentScriptMv3( @@ -80,24 +87,14 @@ export async function reloadRuntimeContentScriptMv3( } await browser.scripting.updateContentScripts(matches); - await reExecuteInMatchingTabs(contentScript); + await reloadTabsForContentScript(contentScript); } -/** - * Re-runs a content script's JS files inside tabs that already have it - * injected, instead of doing a full `tabs.reload()`. A full reload works, but - * it navigates the page, which clears devtools console output - making it hard - * to tell whether the reload actually picked up the change. Running the updated - * files in-place keeps the page (and previous logs) around, so the new console - * output shows up right next to the old output. - */ -async function reExecuteInMatchingTabs(contentScript: ContentScript) { - if (!contentScript.js?.length) return; - +async function reloadTabsForContentScript(contentScript: ContentScript) { + const allTabs = await browser.tabs.query({}); const matchPatterns = contentScript.matches.map( (match) => new MatchPattern(match), ); - const allTabs = await browser.tabs.query({}); const matchingTabs = allTabs.filter((tab) => { const url = tab.url; if (!url) return false;