From efe28133cf16c67ddcdf6af2e121e2da007d8aed Mon Sep 17 00:00:00 2001 From: Salmin Skenderovic Date: Fri, 13 Jun 2025 10:38:35 -0400 Subject: [PATCH 1/6] added new test for dynamic scripts with build watch --- .gitignore | 2 + .../manifest.json | 9 +++++ .../src1/background.ts | 25 ++++++++++++ .../src1/content.ts | 11 +++++ .../src1/header.ts | 1 + .../src2/header.ts | 1 + .../vite-build-watch.test.ts | 40 +++++++++++++++++++ .../vite.config.ts | 10 +++++ 8 files changed, 99 insertions(+) create mode 100644 packages/vite-plugin/tests/e2e/mv3-vite-dynamic-content-script-build-watch/manifest.json create mode 100644 packages/vite-plugin/tests/e2e/mv3-vite-dynamic-content-script-build-watch/src1/background.ts create mode 100644 packages/vite-plugin/tests/e2e/mv3-vite-dynamic-content-script-build-watch/src1/content.ts create mode 100644 packages/vite-plugin/tests/e2e/mv3-vite-dynamic-content-script-build-watch/src1/header.ts create mode 100644 packages/vite-plugin/tests/e2e/mv3-vite-dynamic-content-script-build-watch/src2/header.ts create mode 100644 packages/vite-plugin/tests/e2e/mv3-vite-dynamic-content-script-build-watch/vite-build-watch.test.ts create mode 100644 packages/vite-plugin/tests/e2e/mv3-vite-dynamic-content-script-build-watch/vite.config.ts diff --git a/.gitignore b/.gitignore index ca6d238c7..2f4b43744 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ .vscode **/chromium-data-dir-* **/tests/e2e/mv3-*/src +**/tests/e2e/mv3-*-hmr/src +**/tests/e2e/mv3-*-build-watch/src **/__diff_output__ TODO pnpm-global diff --git a/packages/vite-plugin/tests/e2e/mv3-vite-dynamic-content-script-build-watch/manifest.json b/packages/vite-plugin/tests/e2e/mv3-vite-dynamic-content-script-build-watch/manifest.json new file mode 100644 index 000000000..3ea20cfd6 --- /dev/null +++ b/packages/vite-plugin/tests/e2e/mv3-vite-dynamic-content-script-build-watch/manifest.json @@ -0,0 +1,9 @@ +{ + "description": "test extension", + "manifest_version": 3, + "name": "test extension", + "background": { "service_worker": "src/background.ts" }, + "version": "1.0.0", + "host_permissions": [""], + "permissions": ["scripting"] +} diff --git a/packages/vite-plugin/tests/e2e/mv3-vite-dynamic-content-script-build-watch/src1/background.ts b/packages/vite-plugin/tests/e2e/mv3-vite-dynamic-content-script-build-watch/src1/background.ts new file mode 100644 index 000000000..e320ddeed --- /dev/null +++ b/packages/vite-plugin/tests/e2e/mv3-vite-dynamic-content-script-build-watch/src1/background.ts @@ -0,0 +1,25 @@ +import contentScript from './content?script' + +// This fixes `self`'s type. +declare const self: ServiceWorkerGlobalScope +export { } + +chrome.runtime.onInstalled.addListener(async ({ reason }) => { + if (reason === 'install') { + await self.skipWaiting() + await new Promise((r) => setTimeout(r, 100)) + + chrome.scripting + .registerContentScripts([ + { id: 'contentScript', js: [contentScript], matches: [''] }, + ]) + .catch(console.error) + .then(() => { + console.log('Content script registered successfully.') + + chrome.tabs.create({ + url: 'https://example.com' + }).catch(console.error); + }) + } +}) diff --git a/packages/vite-plugin/tests/e2e/mv3-vite-dynamic-content-script-build-watch/src1/content.ts b/packages/vite-plugin/tests/e2e/mv3-vite-dynamic-content-script-build-watch/src1/content.ts new file mode 100644 index 000000000..f0bfe4386 --- /dev/null +++ b/packages/vite-plugin/tests/e2e/mv3-vite-dynamic-content-script-build-watch/src1/content.ts @@ -0,0 +1,11 @@ +import { header } from './header' + +const app = document.createElement('div') +app.id = 'app' +app.innerHTML = ` +

${header}

+ Documentation +` +document.body.append(app) + +chrome.runtime.sendMessage({ type: 'content-script-load' }) diff --git a/packages/vite-plugin/tests/e2e/mv3-vite-dynamic-content-script-build-watch/src1/header.ts b/packages/vite-plugin/tests/e2e/mv3-vite-dynamic-content-script-build-watch/src1/header.ts new file mode 100644 index 000000000..defec8962 --- /dev/null +++ b/packages/vite-plugin/tests/e2e/mv3-vite-dynamic-content-script-build-watch/src1/header.ts @@ -0,0 +1 @@ +export const header = 'Hello Vite!' diff --git a/packages/vite-plugin/tests/e2e/mv3-vite-dynamic-content-script-build-watch/src2/header.ts b/packages/vite-plugin/tests/e2e/mv3-vite-dynamic-content-script-build-watch/src2/header.ts new file mode 100644 index 000000000..6f1d50fe0 --- /dev/null +++ b/packages/vite-plugin/tests/e2e/mv3-vite-dynamic-content-script-build-watch/src2/header.ts @@ -0,0 +1 @@ +export const header = 'Hello Vite + CRX!' diff --git a/packages/vite-plugin/tests/e2e/mv3-vite-dynamic-content-script-build-watch/vite-build-watch.test.ts b/packages/vite-plugin/tests/e2e/mv3-vite-dynamic-content-script-build-watch/vite-build-watch.test.ts new file mode 100644 index 000000000..9cf9c1ac9 --- /dev/null +++ b/packages/vite-plugin/tests/e2e/mv3-vite-dynamic-content-script-build-watch/vite-build-watch.test.ts @@ -0,0 +1,40 @@ +import fs from 'fs-extra' +import path from 'pathe' +import { expect, test } from 'vitest' +import { createUpdate, getPage } from '../helpers' +import { header } from './src2/header' +import { build } from '../runners' +import { RollupWatcher } from 'rollup' +test( + 'crx page update on build --watch', + async () => { + const src = path.join(__dirname, 'src') + const src1 = path.join(__dirname, 'src1') + const src2 = path.join(__dirname, 'src2') + + await fs.remove(src) + await fs.copy(src1, src, { recursive: true }) + + const { browser, output } = await build(__dirname) + + const update = createUpdate({ target: src, src: src2 }) + const page = await getPage(browser, 'example.com') + + const app = page.locator('#app') + await app.waitFor({ timeout: 15_000 }) + + // update header.ts file -> trigger full reload + await update('header.ts') + + await page.locator('h1', { hasText: header }).waitFor() + + // Validate that there are no plugin:errors? + expect(true).toBe(true) + + // Clean up the watcher + if ('close' in output) { + (output as RollupWatcher).close() + } + }, + { retry: 2 }, +) diff --git a/packages/vite-plugin/tests/e2e/mv3-vite-dynamic-content-script-build-watch/vite.config.ts b/packages/vite-plugin/tests/e2e/mv3-vite-dynamic-content-script-build-watch/vite.config.ts new file mode 100644 index 000000000..299573d08 --- /dev/null +++ b/packages/vite-plugin/tests/e2e/mv3-vite-dynamic-content-script-build-watch/vite.config.ts @@ -0,0 +1,10 @@ +import { crx } from '../../plugin-testOptionsProvider' +import { defineConfig } from 'vite' +import manifest from './manifest.json' + +export default defineConfig({ + build: { minify: false, watch: {} }, + clearScreen: false, + logLevel: 'error', + plugins: [crx({ manifest })], +}) From 0fb971578fdb4cf9a6b9d4528436f4788f5518ba Mon Sep 17 00:00:00 2001 From: Salmin Skenderovic Date: Fri, 13 Jun 2025 10:38:47 -0400 Subject: [PATCH 2/6] return output when watcher provided --- packages/vite-plugin/tests/e2e/runners.ts | 4 ++-- packages/vite-plugin/tests/runners.ts | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/vite-plugin/tests/e2e/runners.ts b/packages/vite-plugin/tests/e2e/runners.ts index 350002d63..bf71f2bf3 100644 --- a/packages/vite-plugin/tests/e2e/runners.ts +++ b/packages/vite-plugin/tests/e2e/runners.ts @@ -25,7 +25,7 @@ afterEach(async () => { }) export async function build(dirname: string) { - const { outDir, config } = await _build(dirname) + const { outDir, config, output } = await _build(dirname) const dataDir = path.join(config.cacheDir!, '.chromium') await fs.remove(dataDir); @@ -41,7 +41,7 @@ export async function build(dirname: string) { }) }) - return { browser, outDir, dataDir } + return { browser, outDir, dataDir, output } } export async function serve(dirname: string) { diff --git a/packages/vite-plugin/tests/runners.ts b/packages/vite-plugin/tests/runners.ts index c31303b70..93b70834d 100644 --- a/packages/vite-plugin/tests/runners.ts +++ b/packages/vite-plugin/tests/runners.ts @@ -1,7 +1,7 @@ import { watch } from 'chokidar' import fs from 'fs-extra' import { join } from 'pathe' -import { RollupOutput } from 'rollup' +import { RollupOutput, RollupWatcher } from 'rollup' import { delay, firstValueFrom, @@ -27,7 +27,7 @@ import { afterEach, expect } from 'vitest' export interface BuildTestResult { command: 'build' config: ResolvedConfig - output: RollupOutput + output: RollupOutput | RollupWatcher outDir: string rootDir: string } @@ -107,7 +107,8 @@ export async function build( if (Array.isArray(output)) throw new TypeError('received outputarray from vite build') - if ('close' in output) throw new TypeError('received watcher from vite build') + // need watcher + // if ('close' in output) throw new TypeError('received watcher from vite build') return { command: 'build', outDir, output, config: config!, rootDir: dirname } } From c474735709f57c1c6e481e9a4f0063cfe62ac9ac Mon Sep 17 00:00:00 2001 From: Salmin Skenderovic Date: Thu, 30 Apr 2026 11:06:52 -0400 Subject: [PATCH 3/6] assert output files instead of using the browser --- .../vite-build-watch.test.ts | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/packages/vite-plugin/tests/e2e/mv3-vite-dynamic-content-script-build-watch/vite-build-watch.test.ts b/packages/vite-plugin/tests/e2e/mv3-vite-dynamic-content-script-build-watch/vite-build-watch.test.ts index 9cf9c1ac9..17bf2644a 100644 --- a/packages/vite-plugin/tests/e2e/mv3-vite-dynamic-content-script-build-watch/vite-build-watch.test.ts +++ b/packages/vite-plugin/tests/e2e/mv3-vite-dynamic-content-script-build-watch/vite-build-watch.test.ts @@ -5,6 +5,7 @@ import { createUpdate, getPage } from '../helpers' import { header } from './src2/header' import { build } from '../runners' import { RollupWatcher } from 'rollup' + test( 'crx page update on build --watch', async () => { @@ -15,7 +16,7 @@ test( await fs.remove(src) await fs.copy(src1, src, { recursive: true }) - const { browser, output } = await build(__dirname) + const { browser, output, outDir } = await build(__dirname) const update = createUpdate({ target: src, src: src2 }) const page = await getPage(browser, 'example.com') @@ -23,17 +24,33 @@ test( const app = page.locator('#app') await app.waitFor({ timeout: 15_000 }) - // update header.ts file -> trigger full reload + // update header.ts file -> trigger rebuild await update('header.ts') - await page.locator('h1', { hasText: header }).waitFor() - - // Validate that there are no plugin:errors? - expect(true).toBe(true) + // wait for the watch rebuild to complete and produce updated output. + // Note: in build --watch mode there is no extension auto-reload, so we + // assert on the produced files rather than the running page. + const assetsDir = path.join(outDir, 'assets') + const deadline = Date.now() + 15_000 + let updated = false + while (Date.now() < deadline) { + const assets = await fs.readdir(assetsDir).catch(() => [] as string[]) + for (const f of assets) { + if (!f.startsWith('content.ts.')) continue + const source = await fs.readFile(path.join(assetsDir, f), 'utf8') + if (source.includes(header)) { + updated = true + break + } + } + if (updated) break + await new Promise((r) => setTimeout(r, 250)) + } + expect(updated).toBe(true) // Clean up the watcher if ('close' in output) { - (output as RollupWatcher).close() + ;(output as RollupWatcher).close() } }, { retry: 2 }, From ca55b37d3f65d15fbd7e3c2c594cf2be857a8231 Mon Sep 17 00:00:00 2001 From: Salmin Skenderovic Date: Thu, 30 Apr 2026 11:07:15 -0400 Subject: [PATCH 4/6] get file name earlier --- packages/vite-plugin/src/node/plugin-contentScripts.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/vite-plugin/src/node/plugin-contentScripts.ts b/packages/vite-plugin/src/node/plugin-contentScripts.ts index 81a9c337c..9c60cdd57 100644 --- a/packages/vite-plugin/src/node/plugin-contentScripts.ts +++ b/packages/vite-plugin/src/node/plugin-contentScripts.ts @@ -180,11 +180,11 @@ export const pluginContentScripts: CrxPluginFn = () => { // emit content script loaders for (const [key, script] of contentScripts) if (key === script.refId) { + const fileName = this.getFileName(script.refId) + if (script.type === 'module') { - const fileName = this.getFileName(script.refId) script.fileName = fileName } else if (script.type === 'loader') { - const fileName = this.getFileName(script.refId) script.fileName = fileName const bundleFileInfo = bundle[fileName] From ee9f37a0ecc476a0682e1d0b44e10f1391e1ee33 Mon Sep 17 00:00:00 2001 From: Salmin Skenderovic Date: Thu, 30 Apr 2026 11:07:34 -0400 Subject: [PATCH 5/6] add buildStart hook to clear rxmap --- .../src/node/plugin-contentScripts_dynamic.ts | 73 ++++++++++++++++++- 1 file changed, 71 insertions(+), 2 deletions(-) diff --git a/packages/vite-plugin/src/node/plugin-contentScripts_dynamic.ts b/packages/vite-plugin/src/node/plugin-contentScripts_dynamic.ts index 629ef3984..265851cf2 100644 --- a/packages/vite-plugin/src/node/plugin-contentScripts_dynamic.ts +++ b/packages/vite-plugin/src/node/plugin-contentScripts_dynamic.ts @@ -43,6 +43,47 @@ export const pluginDynamicContentScripts: CrxPluginFn = () => { configResolved(_config) { config = _config }, + buildStart() { + // In watch mode, Rollup caches resolveId/load results between rebuilds, + // so the contentScripts map may not be repopulated. We must re-emit + // dynamic content script files here to ensure their refIds are valid in + // the new build context. Without this, getFileName() throws errors. + if (config.command === 'build') { + // Snapshot unique dynamic scripts (the map has multiple keys per script) + const dynamicScripts: ContentScript[] = [] + for (const [key, script] of contentScripts) { + if (script.isDynamicScript && key === script.scriptId) { + dynamicScripts.push(script) + } + } + + // Clear stale entries (old refIds, fileNames are invalid in new build) + contentScripts.clear() + + // Re-emit each dynamic script with a fresh refId for this build context + for (const script of dynamicScripts) { + const absoluteId = script.id.startsWith('/') + ? `${config.root}${script.id}` + : `${config.root}/${script.id}` + const refId = this.emitFile({ + type: 'chunk', + id: absoluteId, + name: basename(script.id), + }) + contentScripts.set( + script.id, + formatFileData({ + type: script.type, + id: script.id, + isDynamicScript: true, + refId, + scriptId: script.scriptId, + matches: script.matches, + }), + ) + } + } + }, configureServer(server) { return () => { server.middlewares.use(async (req, res, next) => { @@ -140,9 +181,37 @@ export const pluginDynamicContentScripts: CrxPluginFn = () => { const index = id.indexOf('?scriptId=') if (index > -1) { const scriptId = id.slice(index + '?scriptId='.length) - const script = contentScripts.get(scriptId)! + let script = contentScripts.get(scriptId) + + // In watch mode, Rollup may cache resolveId() but not load(), + // so the map may be empty when load runs. Recreate the entry. + if (!script && config.command === 'build') { + const fileId = id.slice(0, index) + const refId = this.emitFile({ + type: 'chunk', + id: fileId, + name: basename(fileId), + }) + script = formatFileData({ + type: 'loader', + id: relative(config.root, fileId), + isDynamicScript: true, + refId, + scriptId, + matches: [], + }) + contentScripts.set(script.id, script) + } + + if (!script) { + throw new Error(`Content script not found for scriptId: "${scriptId}"`) + } + if (config.command === 'build') { - return `export default import.meta.CRX_DYNAMIC_SCRIPT_${script.refId};` + // Use scriptId (deterministic hash) instead of refId for the placeholder. + // refIds change between watch mode rebuilds, but scriptId is stable. + // This ensures load() cache is valid across rebuilds. + return `export default import.meta.CRX_DYNAMIC_SCRIPT_${script.scriptId};` } else if (typeof script.fileName === 'string') { return `export default ${JSON.stringify(script.fileName)};` } else { From 1aee9ad6241c92764b3ac727d95806e260b7f363 Mon Sep 17 00:00:00 2001 From: Salmin Skenderovic Date: Thu, 30 Apr 2026 22:41:08 -0400 Subject: [PATCH 6/6] fix(vite-plugin): fix build watch for dynamic scripts --- .changeset/fix-build-watch-dynamic-scripts.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-build-watch-dynamic-scripts.md diff --git a/.changeset/fix-build-watch-dynamic-scripts.md b/.changeset/fix-build-watch-dynamic-scripts.md new file mode 100644 index 000000000..de4971d08 --- /dev/null +++ b/.changeset/fix-build-watch-dynamic-scripts.md @@ -0,0 +1,5 @@ +--- +"@crxjs/vite-plugin": patch +--- + +fix: dynamic content scripts failing in build --watch mode