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 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/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] 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 { 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..17bf2644a --- /dev/null +++ b/packages/vite-plugin/tests/e2e/mv3-vite-dynamic-content-script-build-watch/vite-build-watch.test.ts @@ -0,0 +1,57 @@ +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, outDir } = 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 rebuild + await update('header.ts') + + // 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() + } + }, + { 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 })], +}) 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 fe6ad05c0..2ae9a9f27 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 } }