Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-build-watch-dynamic-scripts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@crxjs/vite-plugin": patch
---

fix: dynamic content scripts failing in build --watch mode
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions packages/vite-plugin/src/node/plugin-contentScripts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
73 changes: 71 additions & 2 deletions packages/vite-plugin/src/node/plugin-contentScripts_dynamic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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": ["<all_urls>"],
"permissions": ["scripting"]
}
Original file line number Diff line number Diff line change
@@ -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: ['<all_urls>'] },
])
.catch(console.error)
.then(() => {
console.log('Content script registered successfully.')

chrome.tabs.create({
url: 'https://example.com'
}).catch(console.error);
})
}
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { header } from './header'

const app = document.createElement('div')
app.id = 'app'
app.innerHTML = `
<h1>${header}</h1>
<a href="https://vitejs.dev/guide/features.html" target="_blank">Documentation</a>
`
document.body.append(app)

chrome.runtime.sendMessage({ type: 'content-script-load' })
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const header = 'Hello Vite!'
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const header = 'Hello Vite + CRX!'
Original file line number Diff line number Diff line change
@@ -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 },
)
Original file line number Diff line number Diff line change
@@ -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 })],
})
4 changes: 2 additions & 2 deletions packages/vite-plugin/tests/e2e/runners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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) {
Expand Down
7 changes: 4 additions & 3 deletions packages/vite-plugin/tests/runners.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
}
Expand Down Expand Up @@ -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 }
}
Expand Down
Loading