From 4c1dfd9397d07584f7f16fa1e7d994b03ae80416 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Mon, 24 Aug 2026 20:11:14 +1000 Subject: [PATCH 01/10] fix: skip script downloads for unused components --- packages/script/src/module.ts | 4 +- packages/script/src/plugins/transform.ts | 190 ++++++++++++++++++----- test/e2e/issue-882-unused-widget.test.ts | 17 ++ test/fixtures/issue-882/app.vue | 3 + test/fixtures/issue-882/nuxt.config.ts | 20 +++ test/fixtures/issue-882/package.json | 3 + 6 files changed, 197 insertions(+), 40 deletions(-) create mode 100644 test/e2e/issue-882-unused-widget.test.ts create mode 100644 test/fixtures/issue-882/app.vue create mode 100644 test/fixtures/issue-882/nuxt.config.ts create mode 100644 test/fixtures/issue-882/package.json diff --git a/packages/script/src/module.ts b/packages/script/src/module.ts index 87ba4c28c..c2ce8bc37 100644 --- a/packages/script/src/module.ts +++ b/packages/script/src/module.ts @@ -523,8 +523,9 @@ export default defineNuxtModule({ }) } + const runtimeComponentsDir = await resolvePath('./runtime/components') addComponentsDir({ - path: await resolvePath('./runtime/components'), + path: runtimeComponentsDir, pathPrefix: false, }) @@ -885,6 +886,7 @@ export default defineNuxtModule({ addBuildPlugin(NuxtScriptsCheckScripts()) addBuildPlugin(NuxtScriptBundleTransformer({ nuxt, + componentDir: runtimeComponentsDir, scripts: registryScriptsWithImport, registryConfig: nuxt.options.runtimeConfig.public.scripts as Record | undefined, proxyConfigs, diff --git a/packages/script/src/plugins/transform.ts b/packages/script/src/plugins/transform.ts index 138dc5f00..e7aee05a4 100644 --- a/packages/script/src/plugins/transform.ts +++ b/packages/script/src/plugins/transform.ts @@ -1,6 +1,7 @@ import type { Nuxt } from '@nuxt/schema' import type { FetchOptions } from 'ofetch' import type { SourceMapInput } from 'rollup' +import type { VitePlugin } from 'unplugin' import type { InferInput } from 'valibot' import type { ProxyConfig, ProxyRewrite, RegistryScript } from '../runtime/types' import { createHash } from 'node:crypto' @@ -65,6 +66,11 @@ export interface RenderedScriptMeta { export interface AssetBundlerTransformerOptions { moduleDetected?: (module: string) => void assetsBaseURL?: string + /** + * Runtime component directory. Bundling waits until the final module graph + * proves that an auto-registered component has a real importer. + */ + componentDir?: string scripts?: Required[] /** * Merged configuration from both scripts.registry and runtimeConfig.public.scripts @@ -127,7 +133,8 @@ function normalizeScriptData(src: string, assetsBaseURL: string = '/_scripts/ass } return { url: src } } -async function downloadScript(opts: { + +interface DownloadScriptOptions { src: string url: string filename?: string @@ -138,7 +145,16 @@ async function downloadScript(opts: { skipApiRewrites?: boolean neutralizeCanvas?: boolean assetsBaseURL?: string -}, renderedScript: NonNullable, fetchOptions?: FetchOptions, cacheMaxAge?: number): Promise<{ url: string, filename?: string } | undefined> { +} + +interface PendingComponentBundle { + componentId: string + downloadOptions: DownloadScriptOptions + placeholderIntegrity?: string + placeholderUrl: string +} + +async function downloadScript(opts: DownloadScriptOptions, renderedScript: NonNullable, fetchOptions?: FetchOptions, cacheMaxAge?: number): Promise<{ url: string, filename?: string } | undefined> { const { src, url, filename, forceDownload, integrity, proxyRewrites, sdkPatches, skipApiRewrites, neutralizeCanvas, assetsBaseURL } = opts if (src === url || !filename) { return @@ -223,6 +239,55 @@ async function downloadScript(opts: { return { url: publicUrl, filename: publicFilename } } +async function resolveScriptBundle( + downloadOptions: DownloadScriptOptions, + renderedScript: NonNullable, + options: Pick, +): Promise<{ integrity?: string, url: string }> { + const { src } = downloadOptions + let { url } = downloadOptions + const result = await downloadScript(downloadOptions, renderedScript, options.fetchOptions, options.cacheMaxAge).catch((error: any) => { + if (options.fallbackOnSrcOnBundleFail) { + logger.warn(`[Nuxt Scripts: Bundle Transformer] Failed to bundle ${src}. Fallback to remote loading.`) + return undefined + } + + const errorMessage = error?.message || 'Unknown error' + if (errorMessage.includes('timeout') || errorMessage.includes('network') || errorMessage.includes('ENOTFOUND') || errorMessage.includes('certificate')) { + logger.error(`[Nuxt Scripts: Bundle Transformer] Network issue while bundling ${src}: ${errorMessage}`) + logger.error(`[Nuxt Scripts: Bundle Transformer] Tip: Set 'fallbackOnSrcOnBundleFail: true' in module options or disable bundling in Docker environments`) + } + throw error + }) + + if (result) + url = result.url + else if (options.fallbackOnSrcOnBundleFail) + url = src + + if (src === url) { + if (src.startsWith('/')) + logger.warn(`[Nuxt Scripts: Bundle Transformer] Relative scripts are already bundled. Skipping bundling for \`${src}\`.`) + else + logger.warn(`[Nuxt Scripts: Bundle Transformer] Failed to bundle ${src}.`) + } + + const scriptMeta = renderedScript.get(url) + return { + integrity: scriptMeta instanceof Error ? undefined : scriptMeta?.integrity, + url, + } +} + +function getComponentId(id: string, componentDir?: string): string | undefined { + if (!componentDir) + return + const queryIndex = id.indexOf('?') + const componentId = queryIndex === -1 ? id : id.slice(0, queryIndex) + if (componentId === componentDir || componentId.startsWith(`${componentDir}/`)) + return componentId +} + export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOptions = { renderedScript: new Map(), }) { @@ -257,9 +322,58 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti }) return createUnplugin(() => { + const pendingComponentBundles: PendingComponentBundle[] = [] + const bundleReplacements = new Map() + + const outputHooks: Pick = { + async renderStart() { + for (const pending of pendingComponentBundles) { + const componentInfo = this.getModuleInfo(pending.componentId) + const isUnusedComponent = componentInfo + && componentInfo.importers.length === 0 + && componentInfo.dynamicImporters.length === 0 + + if (isUnusedComponent) { + bundleReplacements.set(pending.placeholderUrl, pending.downloadOptions.src) + if (pending.placeholderIntegrity) + bundleReplacements.set(pending.placeholderIntegrity, '') + continue + } + + const result = await resolveScriptBundle(pending.downloadOptions, renderedScript, options) + bundleReplacements.set(pending.placeholderUrl, result.url) + if (pending.placeholderIntegrity) + bundleReplacements.set(pending.placeholderIntegrity, result.integrity ?? '') + } + pendingComponentBundles.length = 0 + }, + + renderChunk(code, chunk) { + const s = new MagicString(code) + for (const [placeholder, replacement] of bundleReplacements) { + let offset = 0 + while (offset < code.length) { + const index = code.indexOf(placeholder, offset) + if (index === -1) + break + s.overwrite(index, index + placeholder.length, replacement) + offset = index + placeholder.length + } + } + if (s.hasChanged()) { + return { + code: s.toString(), + map: s.generateMap({ includeContent: true, source: chunk.fileName }) as SourceMapInput, + } + } + }, + } + return { name: 'nuxt:scripts:bundler-transformer', + vite: outputHooks, + transform: { filter: { id: { @@ -503,42 +617,7 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti ? (proxyConfig.privacy.hardware ?? true) : true - // Defer async download + MagicString operations - deferredOps.push(async () => { - let url = _url - try { - const result = await downloadScript({ src: src as string, url, filename, forceDownload, proxyRewrites, sdkPatches, integrity: options.integrity, skipApiRewrites, neutralizeCanvas, assetsBaseURL: options.assetsBaseURL }, renderedScript, options.fetchOptions, options.cacheMaxAge) - if (result) { - url = result.url - } - } - catch (e: any) { - if (options.fallbackOnSrcOnBundleFail) { - logger.warn(`[Nuxt Scripts: Bundle Transformer] Failed to bundle ${src}. Fallback to remote loading.`) - url = src as string - } - else { - // Provide more helpful error message, especially for Docker/network issues - const errorMessage = e?.message || 'Unknown error' - if (errorMessage.includes('timeout') || errorMessage.includes('network') || errorMessage.includes('ENOTFOUND') || errorMessage.includes('certificate')) { - logger.error(`[Nuxt Scripts: Bundle Transformer] Network issue while bundling ${src}: ${errorMessage}`) - logger.error(`[Nuxt Scripts: Bundle Transformer] Tip: Set 'fallbackOnSrcOnBundleFail: true' in module options or disable bundling in Docker environments`) - } - throw e - } - } - - if (src === url) { - if (src && (src as string).startsWith('/')) - logger.warn(`[Nuxt Scripts: Bundle Transformer] Relative scripts are already bundled. Skipping bundling for \`${src}\`.`) - else - logger.warn(`[Nuxt Scripts: Bundle Transformer] Failed to bundle ${src}.`) - } - - // Get the integrity hash from rendered script - const scriptMeta = renderedScript.get(url) - const integrityHash = scriptMeta instanceof Error ? undefined : scriptMeta?.integrity - + const rewriteScriptCall = (url: string, integrityHash?: string) => { if (scriptSrcNode) { // For useScript('src') pattern, we need to convert to object form to add integrity if (integrityHash && fnName === 'useScript' && node.arguments[0]?.type === 'Literal') { @@ -589,7 +668,40 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti s.overwrite(node.callee.end, node.end, `({ scriptInput: { src: '${url}'${integrityProps} } })`) } } - }) + } + + const downloadOptions: DownloadScriptOptions = { + src: src as string, + url: _url, + filename, + forceDownload, + proxyRewrites, + sdkPatches, + integrity: options.integrity, + skipApiRewrites, + neutralizeCanvas, + assetsBaseURL: options.assetsBaseURL, + } + const componentId = nuxt.options.dev || nuxt.options.builder !== '@nuxt/vite-builder' + ? undefined + : getComponentId(id, options.componentDir) + + // Nuxt emits every auto-registered component as an entry before it + // knows which components the application imports. Wait for the final + // graph so unused widgets do not trigger third-party downloads. + if (componentId) { + const token = createHash('sha256').update(`${id}:${node.start}:${src}`).digest('hex').slice(0, 16) + const placeholderUrl = `__NUXT_SCRIPT_BUNDLE_${token}__` + const placeholderIntegrity = options.integrity ? `__NUXT_SCRIPT_INTEGRITY_${token}__` : undefined + pendingComponentBundles.push({ componentId, downloadOptions, placeholderIntegrity, placeholderUrl }) + deferredOps.push(async () => rewriteScriptCall(placeholderUrl, placeholderIntegrity)) + } + else { + deferredOps.push(async () => { + const result = await resolveScriptBundle(downloadOptions, renderedScript, options) + rewriteScriptCall(result.url, result.integrity) + }) + } } } } diff --git a/test/e2e/issue-882-unused-widget.test.ts b/test/e2e/issue-882-unused-widget.test.ts new file mode 100644 index 000000000..f02b27156 --- /dev/null +++ b/test/e2e/issue-882-unused-widget.test.ts @@ -0,0 +1,17 @@ +import { createResolver } from '@nuxt/kit' +import { $fetch, setup } from '@nuxt/test-utils/e2e' +import { describe, expect, it } from 'vitest' + +const { resolve } = createResolver(import.meta.url) + +await setup({ + rootDir: resolve('../fixtures/issue-882'), + build: true, + browser: false, +}) + +describe('unused script widgets', () => { + it('builds without downloading their scripts', async () => { + await expect($fetch('/')).resolves.toContain('Nuxt Scripts') + }) +}) diff --git a/test/fixtures/issue-882/app.vue b/test/fixtures/issue-882/app.vue new file mode 100644 index 000000000..d68cfbca1 --- /dev/null +++ b/test/fixtures/issue-882/app.vue @@ -0,0 +1,3 @@ + diff --git a/test/fixtures/issue-882/nuxt.config.ts b/test/fixtures/issue-882/nuxt.config.ts new file mode 100644 index 000000000..ae1018481 --- /dev/null +++ b/test/fixtures/issue-882/nuxt.config.ts @@ -0,0 +1,20 @@ +import { defineNuxtConfig } from 'nuxt/config' + +export default defineNuxtConfig({ + modules: ['@nuxt/scripts'], + scripts: { + assets: { + fetchOptions: { + onRequest({ request }) { + throw new Error(`Unexpected script download: ${request}`) + }, + }, + }, + }, + experimental: { + componentIslands: { + selectiveClient: true, + }, + }, + compatibilityDate: '2024-07-05', +}) diff --git a/test/fixtures/issue-882/package.json b/test/fixtures/issue-882/package.json new file mode 100644 index 000000000..352055cdf --- /dev/null +++ b/test/fixtures/issue-882/package.json @@ -0,0 +1,3 @@ +{ + "private": true +} From aaaab2480bb5cea7ee8068be101578e0ca10ce47 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 26 Aug 2026 20:23:52 +1000 Subject: [PATCH 02/10] fix(transform): drop empty integrity and crossorigin when deferred bundle falls back to remote src --- packages/script/src/plugins/transform.ts | 22 +++- test/unit/bundle-component-integrity.test.ts | 112 +++++++++++++++++++ 2 files changed, 131 insertions(+), 3 deletions(-) create mode 100644 test/unit/bundle-component-integrity.test.ts diff --git a/packages/script/src/plugins/transform.ts b/packages/script/src/plugins/transform.ts index e7aee05a4..2c65f4d17 100644 --- a/packages/script/src/plugins/transform.ts +++ b/packages/script/src/plugins/transform.ts @@ -154,6 +154,16 @@ interface PendingComponentBundle { placeholderUrl: string } +/** + * Every rewrite emits `, integrity: '', crossorigin: 'anonymous'` as one + * unit, so dropping an unresolved hash must remove that whole span: replacing only the + * placeholder would leave `integrity: ''` plus crossorigin, which forces CORS request + * mode and breaks origins serving scripts without CORS headers. + */ +function integrityPlaceholderRemoval(placeholderIntegrity: string): string { + return `, integrity: '${placeholderIntegrity}', crossorigin: 'anonymous'` +} + async function downloadScript(opts: DownloadScriptOptions, renderedScript: NonNullable, fetchOptions?: FetchOptions, cacheMaxAge?: number): Promise<{ url: string, filename?: string } | undefined> { const { src, url, filename, forceDownload, integrity, proxyRewrites, sdkPatches, skipApiRewrites, neutralizeCanvas, assetsBaseURL } = opts if (src === url || !filename) { @@ -336,14 +346,20 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti if (isUnusedComponent) { bundleReplacements.set(pending.placeholderUrl, pending.downloadOptions.src) if (pending.placeholderIntegrity) - bundleReplacements.set(pending.placeholderIntegrity, '') + bundleReplacements.set(integrityPlaceholderRemoval(pending.placeholderIntegrity), '') continue } const result = await resolveScriptBundle(pending.downloadOptions, renderedScript, options) bundleReplacements.set(pending.placeholderUrl, result.url) - if (pending.placeholderIntegrity) - bundleReplacements.set(pending.placeholderIntegrity, result.integrity ?? '') + if (pending.placeholderIntegrity) { + bundleReplacements.set( + result.integrity + ? pending.placeholderIntegrity + : integrityPlaceholderRemoval(pending.placeholderIntegrity), + result.integrity ?? '', + ) + } } pendingComponentBundles.length = 0 }, diff --git a/test/unit/bundle-component-integrity.test.ts b/test/unit/bundle-component-integrity.test.ts new file mode 100644 index 000000000..b7e8d72d9 --- /dev/null +++ b/test/unit/bundle-component-integrity.test.ts @@ -0,0 +1,112 @@ +// Reproduction for the deferred component bundling integrity finding: +// when a bundled script falls back to its remote src (or no hash resolves), +// the rewrite must not leave `integrity: ''` paired with +// `crossorigin: 'anonymous'`, which silently forces CORS request mode. +import type { AssetBundlerTransformerOptions } from '../../packages/script/src/plugins/transform' +import { createHash } from 'node:crypto' +import { hash } from 'ohash' +import { hasProtocol } from 'ufo' +import { describe, expect, it, vi } from 'vitest' +import { NuxtScriptBundleTransformer } from '../../packages/script/src/plugins/transform' + +vi.mock('ohash', async (og) => { + const mod = await og() + return { ...mod, hash: vi.fn(mod.hash) } +}) +vi.mock('ufo', async (og) => { + const mod = await og() + return { ...mod, hasProtocol: vi.fn(mod.hasProtocol) } +}) + +vi.mocked(hasProtocol).mockImplementation(() => true) +vi.mocked(hash).mockImplementation(src => String((src as any).pathname ?? src)) + +const mockBundleStorage: any = { + getItem: vi.fn(), + setItem: vi.fn(), + getItemRaw: vi.fn(), + setItemRaw: vi.fn(), + hasItem: vi.fn(), +} +vi.mock('../../packages/script/src/assets', () => ({ + bundleStorage: vi.fn(() => mockBundleStorage), +})) + +const fetchMock = vi.fn() +vi.stubGlobal('fetch', fetchMock) + +const COMPONENT_ID = '/app/components/BuyWidget.vue' +const COMPONENT_DIR = '/app/components' + +function makeNuxt() { + return { + options: { + dev: false, + builder: '@nuxt/vite-builder', + buildDir: '.nuxt', + app: { baseURL: '/' }, + runtimeConfig: { app: {} }, + }, + hooks: { hook: vi.fn() }, + } as any +} + +async function buildComponentChunk(options: Partial, importers: string[]) { + mockBundleStorage.hasItem.mockResolvedValue(false) + const code = `const instance = useScript('https://example.com/widget.js', { bundle: true })` + const plugin = NuxtScriptBundleTransformer({ + renderedScript: new Map(), + integrity: true, + fallbackOnSrcOnBundleFail: true, + componentDir: COMPONENT_DIR, + ...options, + nuxt: makeNuxt(), + }).vite() as any + + const transformed = await plugin.transform.handler.call({}, code, `${COMPONENT_ID}?vue&type=script&setup=true&lang.ts`) + expect(transformed?.code).toBeTruthy() + + // Simulate rollup's final module graph before chunk rendering. + await plugin.renderStart.call({ + getModuleInfo: () => ({ importers, dynamicImporters: [] }), + }) + + const chunk = await plugin.renderChunk.call({}, transformed.code, { fileName: 'entry.js' }) + return (chunk?.code ?? transformed.code) as string +} + +describe('deferred component bundling integrity placeholders', () => { + it('bundle falls back to remote src -> no empty integrity and no crossorigin', async () => { + fetchMock.mockRejectedValue(new Error('network down')) + const code = await buildComponentChunk({}, [`${COMPONENT_ID}:importer`]) + + expect(code).toContain('https://example.com/widget.js') + expect(code).not.toContain(`crossorigin`) + expect(code).not.toMatch(/integrity:\s*['"`]['"`]/) + }) + + it('unused component falls back to remote src -> no empty integrity and no crossorigin', async () => { + fetchMock.mockRejectedValue(new Error('network down')) + const code = await buildComponentChunk({}, []) + + expect(code).toContain('https://example.com/widget.js') + expect(code).not.toContain(`crossorigin`) + expect(code).not.toMatch(/integrity:\s*['"`]['"`]/) + }) + + it('successful bundle keeps the real integrity hash with crossorigin', async () => { + const body = Buffer.from('/* widget */ console.log("widget")') + fetchMock.mockResolvedValue({ + ok: true, + arrayBuffer: () => Promise.resolve(body), + headers: { get: () => null }, + _data: body, + }) + const code = await buildComponentChunk({}, [`${COMPONENT_ID}:importer`]) + + const expected = `sha384-${createHash('sha384').update(body).digest('base64')}` + expect(code).toMatch(/\/_scripts\/assets\/[a-f0-9]{16}\.js/) + expect(code).toContain(`integrity: '${expected}'`) + expect(code).toContain(`crossorigin: 'anonymous'`) + }) +}) From e8b907ab1a451b0151e527c2fd6ab99125343e1b Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Thu, 27 Aug 2026 01:10:44 +1000 Subject: [PATCH 03/10] test(e2e): cover deferred used-component bundling with integrity + crossorigin --- test/e2e/issue-882-used-widget.test.ts | 76 +++++++++++++++++++++ test/fixtures/issue-882-used/app.vue | 9 +++ test/fixtures/issue-882-used/nuxt.config.ts | 16 +++++ test/fixtures/issue-882-used/package.json | 3 + 4 files changed, 104 insertions(+) create mode 100644 test/e2e/issue-882-used-widget.test.ts create mode 100644 test/fixtures/issue-882-used/app.vue create mode 100644 test/fixtures/issue-882-used/nuxt.config.ts create mode 100644 test/fixtures/issue-882-used/package.json diff --git a/test/e2e/issue-882-used-widget.test.ts b/test/e2e/issue-882-used-widget.test.ts new file mode 100644 index 000000000..1f8e7470e --- /dev/null +++ b/test/e2e/issue-882-used-widget.test.ts @@ -0,0 +1,76 @@ +import { createHash } from 'node:crypto' +import { createResolver } from '@nuxt/kit' +import { $fetch, setup } from '@nuxt/test-utils/e2e' +import { describe, expect, it } from 'vitest' + +const { resolve } = createResolver(import.meta.url) + +await setup({ + rootDir: resolve('../fixtures/issue-882-used'), + build: true, + browser: false, +}) + +const ABS_CHUNK_RE = /\/_nuxt\/[\w-]+\.js/g +const REL_CHUNK_RE = /\.\/([\w-]+\.js)/g + +/** + * Walk the built client module graph over HTTP, starting from the entry chunk + * referenced by the served page's import map, until we find the chunk that + * carries the rewritten `useScript*` call for the bundled widget. Chunks are + * connected by both absolute (`/_nuxt/x.js`) and relative (`./x.js`) specifiers. + */ +async function findWidgetChunk(entryUrl: string, marker: string): Promise { + const queue = [entryUrl] + const visited = new Set() + const hits: string[] = [] + let guard = 0 + while (queue.length && guard < 200) { + guard++ + const url = queue.shift()! + if (visited.has(url)) + continue + visited.add(url) + const code = await $fetch(url) + const refs = new Set() + for (const ref of code.match(ABS_CHUNK_RE) || []) + refs.add(ref) + for (const ref of code.match(REL_CHUNK_RE) || []) + refs.add(`/_nuxt/${ref.slice(2)}`) + for (const ref of refs) { + if (!visited.has(ref)) + queue.push(ref) + } + if (code.includes(marker)) + hits.push(code) + } + return hits +} + +describe('used script widget (deferred component path)', () => { + it('bundles the used widget script and keeps integrity + crossorigin through renderStart', async () => { + const html = await $fetch('/') + expect(html).toContain('Nuxt Scripts') + + // The deferred used-component path must resolve the placeholder to the + // content-addressed public bundle URL rather than the remote src. + const assetUrl = html.match(/\/_scripts\/assets\/[a-f0-9]{16}\.js/)?.[0] + expect(assetUrl, 'expected a bundled /_scripts/assets/.js reference in the served page').toBeTruthy() + + // The integrity hash computed on the served bundle must match the hash the + // deferred renderStart path baked into the page (the script preload link). + const assetBody = await $fetch(assetUrl!) + const expectedIntegrity = `sha384-${createHash('sha384').update(assetBody).digest('base64')}` + expect(html).toContain(`integrity="${expectedIntegrity}"`) + + // The same src + integrity + crossorigin must survive into the built client + // chunk that drives the runtime script injection. + const entry = html.match(/"#entry":"(\/_nuxt\/[\w-]+\.js)"/)?.[1] + expect(entry, 'expected an entry chunk in the served page import map').toBeTruthy() + const widgetChunks = await findWidgetChunk(entry!, assetUrl!) + expect(widgetChunks.length, 'expected a built client chunk referencing the bundled asset').toBeGreaterThan(0) + const rewritten = widgetChunks.join('\n') + expect(rewritten).toContain(`integrity:\`${expectedIntegrity}\``) + expect(rewritten).toContain(`crossorigin:\`anonymous\``) + }) +}) diff --git a/test/fixtures/issue-882-used/app.vue b/test/fixtures/issue-882-used/app.vue new file mode 100644 index 000000000..3e48ee06c --- /dev/null +++ b/test/fixtures/issue-882-used/app.vue @@ -0,0 +1,9 @@ + diff --git a/test/fixtures/issue-882-used/nuxt.config.ts b/test/fixtures/issue-882-used/nuxt.config.ts new file mode 100644 index 000000000..1d9f38d57 --- /dev/null +++ b/test/fixtures/issue-882-used/nuxt.config.ts @@ -0,0 +1,16 @@ +import { defineNuxtConfig } from 'nuxt/config' + +export default defineNuxtConfig({ + modules: ['@nuxt/scripts'], + scripts: { + assets: { + integrity: true, + }, + }, + experimental: { + componentIslands: { + selectiveClient: true, + }, + }, + compatibilityDate: '2024-07-05', +}) diff --git a/test/fixtures/issue-882-used/package.json b/test/fixtures/issue-882-used/package.json new file mode 100644 index 000000000..352055cdf --- /dev/null +++ b/test/fixtures/issue-882-used/package.json @@ -0,0 +1,3 @@ +{ + "private": true +} From 50104d2c53406c9787f2d7c866f6826df1f1ff9a Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Thu, 27 Aug 2026 12:51:55 +1000 Subject: [PATCH 04/10] perf(transform): overlap deferred component bundle downloads in renderStart --- packages/script/src/plugins/transform.ts | 17 ++- .../render-start-concurrent-downloads.test.ts | 143 ++++++++++++++++++ 2 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 test/unit/render-start-concurrent-downloads.test.ts diff --git a/packages/script/src/plugins/transform.ts b/packages/script/src/plugins/transform.ts index 2c65f4d17..d357e4df7 100644 --- a/packages/script/src/plugins/transform.ts +++ b/packages/script/src/plugins/transform.ts @@ -337,20 +337,31 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti const outputHooks: Pick = { async renderStart() { - for (const pending of pendingComponentBundles) { + // Downloads must overlap: awaiting inside the loop would make the build + // wall-clock time the sum of every script's latency. Each item keeps its + // own catch/rethrow semantics (fallback or fatal) via resolveScriptBundle; + // Promise.all preserves fatal-error propagation. + const settled = await Promise.all(pendingComponentBundles.map(async (pending): Promise< + { pending: PendingComponentBundle, result?: { integrity?: string, url: string } } + > => { const componentInfo = this.getModuleInfo(pending.componentId) const isUnusedComponent = componentInfo && componentInfo.importers.length === 0 && componentInfo.dynamicImporters.length === 0 - if (isUnusedComponent) { + if (isUnusedComponent) + return { pending } + + return { pending, result: await resolveScriptBundle(pending.downloadOptions, renderedScript, options) } + })) + for (const { pending, result } of settled) { + if (!result) { bundleReplacements.set(pending.placeholderUrl, pending.downloadOptions.src) if (pending.placeholderIntegrity) bundleReplacements.set(integrityPlaceholderRemoval(pending.placeholderIntegrity), '') continue } - const result = await resolveScriptBundle(pending.downloadOptions, renderedScript, options) bundleReplacements.set(pending.placeholderUrl, result.url) if (pending.placeholderIntegrity) { bundleReplacements.set( diff --git a/test/unit/render-start-concurrent-downloads.test.ts b/test/unit/render-start-concurrent-downloads.test.ts new file mode 100644 index 000000000..b72179778 --- /dev/null +++ b/test/unit/render-start-concurrent-downloads.test.ts @@ -0,0 +1,143 @@ +// Regression: deferred component bundle downloads in renderStart must overlap. +// A sequential `for...of` + `await` makes the build wall-clock time the sum of +// every used component's script latency instead of the slowest one. +import type { AssetBundlerTransformerOptions } from '../../packages/script/src/plugins/transform' +import { describe, expect, it, vi } from 'vitest' +import { NuxtScriptBundleTransformer } from '../../packages/script/src/plugins/transform' + +const mockBundleStorage: any = { + getItem: vi.fn(), + setItem: vi.fn(), + getItemRaw: vi.fn(), + setItemRaw: vi.fn(), + hasItem: vi.fn(), +} +vi.mock('../../packages/script/src/assets', () => ({ + bundleStorage: vi.fn(() => mockBundleStorage), +})) + +const fetchMock = vi.fn() +vi.stubGlobal('fetch', fetchMock) + +const COMPONENT_DIR = '/app/components' + +function makeNuxt() { + return { + options: { + dev: false, + builder: '@nuxt/vite-builder', + buildDir: '.nuxt', + app: { baseURL: '/' }, + runtimeConfig: { app: {} }, + }, + hooks: { hook: vi.fn() }, + } as any +} + +function makePlugin(options: Partial = {}) { + mockBundleStorage.hasItem.mockResolvedValue(false) + return NuxtScriptBundleTransformer({ + renderedScript: new Map(), + integrity: true, + fallbackOnSrcOnBundleFail: true, + componentDir: COMPONENT_DIR, + ...options, + nuxt: makeNuxt(), + }).vite() as any +} + +async function registerComponent(plugin: any, file: string, src: string) { + const code = `const instance = useScript('${src}', { bundle: true })` + const id = `${COMPONENT_DIR}/${file}?vue&type=script&setup=true&lang.ts` + const transformed = await plugin.transform.handler.call({}, code, id) + expect(transformed?.code).toBeTruthy() + return transformed.code +} + +describe('renderStart concurrent deferred downloads', () => { + it('starts every used-component download before the first one resolves', async () => { + const calls: string[] = [] + const gates: Array<() => void> = [] + fetchMock.mockImplementation((url: string) => { + calls.push(url) + let openGate!: () => void + const gate = new Promise((resolve) => { + openGate = resolve + }) + gates.push(openGate) + // The download stays in flight until we release its gate. + return gate.then(() => ({ + ok: true, + headers: { get: () => null }, + _data: Buffer.from(`/* ${url} */`), + })) + }) + + const plugin = makePlugin() + await registerComponent(plugin, 'Alpha.vue', 'https://example.com/alpha.js') + await registerComponent(plugin, 'Beta.vue', 'https://example.com/beta.js') + + const running = plugin.renderStart.call({ + getModuleInfo: () => ({ importers: ['entry'], dynamicImporters: [] }), + }) + expect(running).toBeInstanceOf(Promise) + + // First download started; the response is still pending. + await vi.waitFor(() => expect(calls.length).toBeGreaterThanOrEqual(1)) + // Sequential execution can never reach the second download while the + // first response is parked, so give it ample microtask/scheduler turns. + await new Promise(resolve => setTimeout(resolve, 20)) + + expect(calls).toEqual([ + 'https://example.com/alpha.js', + 'https://example.com/beta.js', + ]) + + gates.forEach(gate => gate()) + await running + }) + + it('still renders both bundles correctly after overlapping downloads', async () => { + fetchMock.mockImplementation((url: string) => Promise.resolve({ + ok: true, + headers: { get: () => null }, + _data: Buffer.from(`/* ${url} */`), + })) + + const plugin = makePlugin() + const codeA = await registerComponent(plugin, 'Alpha.vue', 'https://example.com/alpha.js') + const codeB = await registerComponent(plugin, 'Beta.vue', 'https://example.com/beta.js') + + await plugin.renderStart.call({ + getModuleInfo: () => ({ importers: ['entry'], dynamicImporters: [] }), + }) + + for (const [code, original] of [[codeA, 'alpha'], [codeB, 'beta']] as const) { + const chunk = await plugin.renderChunk.call({}, code, { fileName: `chunk-${original}.js` }) + expect(chunk?.code).toMatch(/\/_scripts\/assets\/[a-f0-9]{16}\.js/) + expect(chunk?.code).not.toContain('__NUXT_SCRIPT_BUNDLE_') + expect(chunk?.code).not.toContain('https://example.com') + } + }) + + it('fatal download failure still rejects renderStart when fallback is disabled', async () => { + fetchMock.mockImplementation((url: string) => { + if (url.includes('broken')) { + return Promise.resolve({ ok: false, status: 500, headers: { get: () => null }, _data: undefined, arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)) }) + } + return Promise.resolve({ + ok: true, + headers: { get: () => null }, + _data: Buffer.from(`/* ${url} */`), + }) + }) + + const plugin = makePlugin({ fallbackOnSrcOnBundleFail: false }) + await registerComponent(plugin, 'Good.vue', 'https://example.com/good.js') + await registerComponent(plugin, 'Broken.vue', 'https://example.com/broken.js') + + await expect(plugin.renderStart.call({ + getModuleInfo: () => ({ importers: ['entry'], dynamicImporters: [] }), + })).rejects.toThrow(/broken\.js/) + }) +}) From 1ed7610fd01638282ddf51e52209df4f35f87adb Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Thu, 27 Aug 2026 15:53:07 +1000 Subject: [PATCH 05/10] fix(transform): resolve deferred placeholders at emit time --- packages/script/src/plugins/transform.ts | 158 ++++++++++++------ test/e2e/issue-882-unused-widget.test.ts | 30 +++- test/e2e/issue-882-used-widget.test.ts | 79 ++++----- test/unit/bundle-component-integrity.test.ts | 18 +- .../bundle-component-reachability.test.ts | 112 +++++++++++++ .../bundle-placeholder-minification.test.ts | 79 +++++++++ .../render-start-concurrent-downloads.test.ts | 51 +++--- 7 files changed, 404 insertions(+), 123 deletions(-) create mode 100644 test/unit/bundle-component-reachability.test.ts create mode 100644 test/unit/bundle-placeholder-minification.test.ts diff --git a/packages/script/src/plugins/transform.ts b/packages/script/src/plugins/transform.ts index d357e4df7..ae11c7a01 100644 --- a/packages/script/src/plugins/transform.ts +++ b/packages/script/src/plugins/transform.ts @@ -155,13 +155,21 @@ interface PendingComponentBundle { } /** - * Every rewrite emits `, integrity: '', crossorigin: 'anonymous'` as one - * unit, so dropping an unresolved hash must remove that whole span: replacing only the - * placeholder would leave `integrity: ''` plus crossorigin, which forces CORS request - * mode and breaks origins serving scripts without CORS headers. + * Dropping an unresolved hash must remove the whole `, integrity: ..., crossorigin: 'anonymous'` + * span: replacing only the placeholder would leave `integrity: ''` plus crossorigin, which + * forces CORS request mode and breaks origins serving scripts without CORS headers. + * + * The patch runs against final minified chunk code, where quote style and whitespace are + * not ours to choose (oxc renders every literal as a template literal), so match the two + * properties structurally instead of comparing exact source text. */ -function integrityPlaceholderRemoval(placeholderIntegrity: string): string { - return `, integrity: '${placeholderIntegrity}', crossorigin: 'anonymous'` +function integrityPlaceholderRemoval(placeholderIntegrity: string): RegExp { + const token = escapeRegExp(placeholderIntegrity) + return new RegExp(`,\\s*integrity\\s*:\\s*["'\`]${token}["'\`]\\s*,\\s*crossorigin\\s*:\\s*["'\`][^"'\`]*["'\`]`, 'g') +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } async function downloadScript(opts: DownloadScriptOptions, renderedScript: NonNullable, fetchOptions?: FetchOptions, cacheMaxAge?: number): Promise<{ url: string, filename?: string } | undefined> { @@ -333,64 +341,104 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti return createUnplugin(() => { const pendingComponentBundles: PendingComponentBundle[] = [] - const bundleReplacements = new Map() - - const outputHooks: Pick = { - async renderStart() { - // Downloads must overlap: awaiting inside the loop would make the build - // wall-clock time the sum of every script's latency. Each item keeps its - // own catch/rethrow semantics (fallback or fatal) via resolveScriptBundle; - // Promise.all preserves fatal-error propagation. - const settled = await Promise.all(pendingComponentBundles.map(async (pending): Promise< - { pending: PendingComponentBundle, result?: { integrity?: string, url: string } } - > => { - const componentInfo = this.getModuleInfo(pending.componentId) - const isUnusedComponent = componentInfo - && componentInfo.importers.length === 0 - && componentInfo.dynamicImporters.length === 0 - - if (isUnusedComponent) - return { pending } - - return { pending, result: await resolveScriptBundle(pending.downloadOptions, renderedScript, options) } - })) - for (const { pending, result } of settled) { - if (!result) { - bundleReplacements.set(pending.placeholderUrl, pending.downloadOptions.src) + const replacements = new Map() + + /** + * A pending component is unused unless some importer path reaches a module outside + * the runtime components dir. Direct importers alone miss nested widgets: an + * auto-registered parent that nothing references can still make its children look + * used. Cycles (A imports B imports A) are guarded by the visited set. + */ + function reachesOutsideComponentDir(componentId: string, getModuleInfo: (id: string) => { importers?: string[], dynamicImporters?: string[] } | undefined | null): boolean { + if (!options.componentDir) + return true + const stack = [componentId] + const visited = new Set() + while (stack.length > 0) { + const id = stack.pop()! + if (visited.has(id)) + continue + visited.add(id) + if (getComponentId(id, options.componentDir) === undefined) + return true + const info = getModuleInfo(id) + if (!info) + continue + stack.push(...(info.importers ?? []), ...(info.dynamicImporters ?? [])) + } + return false + } + + function applyReplacements(code: string): string { + let result: MagicString | undefined + for (const [placeholder, replacement] of replacements) { + if (placeholder instanceof RegExp) { + placeholder.lastIndex = 0 + for (let match = placeholder.exec(code); match; match = placeholder.exec(code)) { + result ??= new MagicString(code) + result.remove(match.index, match.index + match[0].length) + if (match[0].length === 0) + break + } + continue + } + let offset = 0 + while (offset < code.length) { + const index = code.indexOf(placeholder, offset) + if (index === -1) + break + result ??= new MagicString(code) + result.overwrite(index, index + placeholder.length, replacement) + offset = index + placeholder.length + } + } + return result ? result.toString() : code + } + + const outputHooks: Pick = { + async generateBundle(_outputOptions, bundle) { + if (pendingComponentBundles.length === 0) + return + + // Bundling has finished handing us the final module graph here and the bundler + // awaits this hook before writing files, so classification, downloads and patching + // land in a single deterministic point. An awaited renderStart cannot do this job: + // rolldown renders chunks without waiting for it, which shipped unresolved + // placeholders whenever a download outlived rendering. + await Promise.all(pendingComponentBundles.map(async (pending) => { + const isUnusedComponent = !reachesOutsideComponentDir(pending.componentId, id => this.getModuleInfo(id)) + + if (isUnusedComponent) { + replacements.set(pending.placeholderUrl, pending.downloadOptions.src) if (pending.placeholderIntegrity) - bundleReplacements.set(integrityPlaceholderRemoval(pending.placeholderIntegrity), '') - continue + replacements.set(integrityPlaceholderRemoval(pending.placeholderIntegrity), '') + return } - bundleReplacements.set(pending.placeholderUrl, result.url) + // Downloads overlap across pendings: resolveScriptBundle rethrows fatal failures + // (only falling back when explicitly configured), and Promise.all preserves that. + const result = await resolveScriptBundle(pending.downloadOptions, renderedScript, options) + + replacements.set(pending.placeholderUrl, result.url) if (pending.placeholderIntegrity) { - bundleReplacements.set( - result.integrity - ? pending.placeholderIntegrity - : integrityPlaceholderRemoval(pending.placeholderIntegrity), + replacements.set( + result.integrity ? pending.placeholderIntegrity : integrityPlaceholderRemoval(pending.placeholderIntegrity), result.integrity ?? '', ) } - } + })) pendingComponentBundles.length = 0 - }, - renderChunk(code, chunk) { - const s = new MagicString(code) - for (const [placeholder, replacement] of bundleReplacements) { - let offset = 0 - while (offset < code.length) { - const index = code.indexOf(placeholder, offset) - if (index === -1) - break - s.overwrite(index, index + placeholder.length, replacement) - offset = index + placeholder.length - } - } - if (s.hasChanged()) { - return { - code: s.toString(), - map: s.generateMap({ includeContent: true, source: chunk.fileName }) as SourceMapInput, + // Mutating `bundle` entries is honored on write (rollup contract); renderChunk-based + // patching was not: rolldown may render before any map entry existed. + for (const file of Object.values(bundle)) { + if (file.type !== 'chunk') + continue + const patched = applyReplacements(file.code) + if (patched !== file.code) { + // Edits only touch token spans inserted at transform time, so the existing + // sourcemap stays usable; regenerating one here would lose all chunk mappings. + file.code = patched } } }, diff --git a/test/e2e/issue-882-unused-widget.test.ts b/test/e2e/issue-882-unused-widget.test.ts index f02b27156..a5ffedb7c 100644 --- a/test/e2e/issue-882-unused-widget.test.ts +++ b/test/e2e/issue-882-unused-widget.test.ts @@ -1,5 +1,7 @@ +import { readdir, readFile } from 'node:fs/promises' +import { join } from 'node:path' import { createResolver } from '@nuxt/kit' -import { $fetch, setup } from '@nuxt/test-utils/e2e' +import { $fetch, setup, useTestContext } from '@nuxt/test-utils/e2e' import { describe, expect, it } from 'vitest' const { resolve } = createResolver(import.meta.url) @@ -10,8 +12,34 @@ await setup({ browser: false, }) +/** + * Placeholder tokens are inserted at transform time and must be fully resolved before + * files are written. The deferred removal path used to key on unminified source text, + * which production minification (e.g. oxc template-literal quoting) never matched. + */ +async function readClientChunks(): Promise { + const ctx = useTestContext() + const nitroOutputDir = ctx.nuxt + ? ctx.nuxt.options.nitro.output.dir + : ctx.options.nuxtConfig?.nitro?.output?.dir + expect(nitroOutputDir, 'expected the test context to expose the nitro output dir').toBeTruthy() + const clientChunkDir = join(nitroOutputDir!, 'public', '_nuxt') + const entries = await readdir(clientChunkDir) + return Promise.all( + entries.filter(name => name.endsWith('.js')).map(name => readFile(join(clientChunkDir, name), 'utf-8')), + ) +} + describe('unused script widgets', () => { it('builds without downloading their scripts', async () => { await expect($fetch('/')).resolves.toContain('Nuxt Scripts') }) + + it('ships no unresolved bundle placeholders in any client chunk', async () => { + const chunks = await readClientChunks() + expect(chunks.length).toBeGreaterThan(0) + for (const code of chunks) { + expect(code, 'a client chunk still contains a placeholder token').not.toContain('__NUXT_SCRIPT_') + } + }) }) diff --git a/test/e2e/issue-882-used-widget.test.ts b/test/e2e/issue-882-used-widget.test.ts index 1f8e7470e..49e4b1d4e 100644 --- a/test/e2e/issue-882-used-widget.test.ts +++ b/test/e2e/issue-882-used-widget.test.ts @@ -1,6 +1,8 @@ import { createHash } from 'node:crypto' +import { readdir, readFile } from 'node:fs/promises' +import { join } from 'node:path' import { createResolver } from '@nuxt/kit' -import { $fetch, setup } from '@nuxt/test-utils/e2e' +import { $fetch, setup, useTestContext } from '@nuxt/test-utils/e2e' import { describe, expect, it } from 'vitest' const { resolve } = createResolver(import.meta.url) @@ -11,40 +13,34 @@ await setup({ browser: false, }) -const ABS_CHUNK_RE = /\/_nuxt\/[\w-]+\.js/g -const REL_CHUNK_RE = /\.\/([\w-]+\.js)/g +/** + * Minifiers pick the quoting style per string literal (esbuild preserves the + * source quotes, oxc/rolldown normalizes to template literals), so attribute + * assertions must accept every quoting form. + */ +function attrValueRe(name: string, value: string): RegExp { + const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + return new RegExp(`${name}\\s*:\\s*["'\`]${escaped}["'\`]`) +} /** - * Walk the built client module graph over HTTP, starting from the entry chunk - * referenced by the served page's import map, until we find the chunk that - * carries the rewritten `useScript*` call for the bundled widget. Chunks are - * connected by both absolute (`/_nuxt/x.js`) and relative (`./x.js`) specifiers. + * Read the emitted client chunks of the running test-utils build. Chunk naming + * and island import-map reachability differ between a manual `nuxt build` + * (.output) and @nuxt/test-utils builds (.nuxt/test//output), and not every + * dynamically loaded island chunk is reachable by walking module specifiers + * over HTTP — so read the files Nitro copied into its public dir directly. */ -async function findWidgetChunk(entryUrl: string, marker: string): Promise { - const queue = [entryUrl] - const visited = new Set() - const hits: string[] = [] - let guard = 0 - while (queue.length && guard < 200) { - guard++ - const url = queue.shift()! - if (visited.has(url)) - continue - visited.add(url) - const code = await $fetch(url) - const refs = new Set() - for (const ref of code.match(ABS_CHUNK_RE) || []) - refs.add(ref) - for (const ref of code.match(REL_CHUNK_RE) || []) - refs.add(`/_nuxt/${ref.slice(2)}`) - for (const ref of refs) { - if (!visited.has(ref)) - queue.push(ref) - } - if (code.includes(marker)) - hits.push(code) - } - return hits +async function readClientChunks(): Promise { + const ctx = useTestContext() + const nitroOutputDir = ctx.nuxt + ? ctx.nuxt.options.nitro.output.dir + : ctx.options.nuxtConfig?.nitro?.output?.dir + expect(nitroOutputDir, 'expected the test context to expose the nitro output dir').toBeTruthy() + const clientChunkDir = join(nitroOutputDir!, 'public', '_nuxt') + const entries = await readdir(clientChunkDir) + return Promise.all( + entries.filter(name => name.endsWith('.js')).map(name => readFile(join(clientChunkDir, name), 'utf-8')), + ) } describe('used script widget (deferred component path)', () => { @@ -65,12 +61,17 @@ describe('used script widget (deferred component path)', () => { // The same src + integrity + crossorigin must survive into the built client // chunk that drives the runtime script injection. - const entry = html.match(/"#entry":"(\/_nuxt\/[\w-]+\.js)"/)?.[1] - expect(entry, 'expected an entry chunk in the served page import map').toBeTruthy() - const widgetChunks = await findWidgetChunk(entry!, assetUrl!) - expect(widgetChunks.length, 'expected a built client chunk referencing the bundled asset').toBeGreaterThan(0) - const rewritten = widgetChunks.join('\n') - expect(rewritten).toContain(`integrity:\`${expectedIntegrity}\``) - expect(rewritten).toContain(`crossorigin:\`anonymous\``) + const clientChunks = await readClientChunks() + expect(clientChunks.length, 'expected built client chunks on disk').toBeGreaterThan(0) + const widgetChunk = clientChunks.find(code => code.includes(assetUrl!)) + expect(widgetChunk, 'expected a built client chunk referencing the bundled asset').toBeTruthy() + expect(widgetChunk!).toMatch(attrValueRe('integrity', expectedIntegrity)) + expect(widgetChunk!).toMatch(attrValueRe('crossorigin', 'anonymous')) + + // Unused auto-registered widgets fall back to their remote src; their unresolved + // integrity placeholders must not ship into any production chunk either. + for (const code of clientChunks) { + expect(code, 'a client chunk still contains a placeholder token').not.toContain('__NUXT_SCRIPT_') + } }) }) diff --git a/test/unit/bundle-component-integrity.test.ts b/test/unit/bundle-component-integrity.test.ts index b7e8d72d9..4782318ca 100644 --- a/test/unit/bundle-component-integrity.test.ts +++ b/test/unit/bundle-component-integrity.test.ts @@ -37,6 +37,8 @@ vi.stubGlobal('fetch', fetchMock) const COMPONENT_ID = '/app/components/BuyWidget.vue' const COMPONENT_DIR = '/app/components' +// An importer path must exit the runtime components dir for the component to count as used. +const APP_IMPORTER = '/app/pages/index.vue' function makeNuxt() { return { @@ -66,19 +68,17 @@ async function buildComponentChunk(options: Partial ({ importers, dynamicImporters: [] }), - }) - - const chunk = await plugin.renderChunk.call({}, transformed.code, { fileName: 'entry.js' }) - return (chunk?.code ?? transformed.code) as string + // Simulate rollup's final module graph before files are written. + const getModuleInfo = () => ({ importers, dynamicImporters: [] }) + const bundle = { 'entry.js': { type: 'chunk', code: transformed.code } as any } + await plugin.generateBundle.call({ getModuleInfo }, {}, bundle) + return bundle['entry.js'].code as string } describe('deferred component bundling integrity placeholders', () => { it('bundle falls back to remote src -> no empty integrity and no crossorigin', async () => { fetchMock.mockRejectedValue(new Error('network down')) - const code = await buildComponentChunk({}, [`${COMPONENT_ID}:importer`]) + const code = await buildComponentChunk({}, [APP_IMPORTER]) expect(code).toContain('https://example.com/widget.js') expect(code).not.toContain(`crossorigin`) @@ -102,7 +102,7 @@ describe('deferred component bundling integrity placeholders', () => { headers: { get: () => null }, _data: body, }) - const code = await buildComponentChunk({}, [`${COMPONENT_ID}:importer`]) + const code = await buildComponentChunk({}, [APP_IMPORTER]) const expected = `sha384-${createHash('sha384').update(body).digest('base64')}` expect(code).toMatch(/\/_scripts\/assets\/[a-f0-9]{16}\.js/) diff --git a/test/unit/bundle-component-reachability.test.ts b/test/unit/bundle-component-reachability.test.ts new file mode 100644 index 000000000..3bb1f4764 --- /dev/null +++ b/test/unit/bundle-component-reachability.test.ts @@ -0,0 +1,112 @@ +// Regression coverage for nested auto-registered widgets: a pending component whose +// importer chain never leaves the runtime components dir must not trigger any +// third-party download, even when its direct importer is another (unreferenced) +// component with importers of its own. +import type { AssetBundlerTransformerOptions } from '../../packages/script/src/plugins/transform' +import { describe, expect, it, vi } from 'vitest' +import { NuxtScriptBundleTransformer } from '../../packages/script/src/plugins/transform' + +vi.mock('../../packages/script/src/assets', () => ({ + bundleStorage: vi.fn(() => ({ + getItem: vi.fn(), + setItem: vi.fn(), + getItemRaw: vi.fn(), + setItemRaw: vi.fn(), + hasItem: vi.fn().mockResolvedValue(false), + })), +})) + +const fetchMock = vi.fn() +vi.stubGlobal('fetch', fetchMock) + +const COMPONENT_DIR = '/app/node_modules/@nuxt/scripts/dist/runtime/components' +const TRACKER_ID = `${COMPONENT_DIR}/Tracker.vue` +const PARENT_ID = `${COMPONENT_DIR}/UnusedParent.vue` +const CYCLE_SIBLING_ID = `${COMPONENT_DIR}/CycleSibling.vue` +const PAGE_ID = '/app/pages/index.vue' +const TRACKER_SRC = 'https://example.com/tracker.js' + +function makeNuxt() { + return { + options: { + dev: false, + builder: '@nuxt/vite-builder', + buildDir: '.nuxt', + app: { baseURL: '/' }, + runtimeConfig: { app: {} }, + }, + hooks: { hook: vi.fn() }, + } as any +} + +async function deferScript(options?: Partial, id: string = TRACKER_ID) { + const code = `const instance = useScript('${TRACKER_SRC}', { bundle: true })` + const plugin = NuxtScriptBundleTransformer({ + renderedScript: new Map(), + componentDir: COMPONENT_DIR, + fallbackOnSrcOnBundleFail: true, + ...options, + nuxt: makeNuxt(), + }).vite() as any + + const transformed = await plugin.transform.handler.call({}, code, `${id}?vue&type=script&setup=true&lang.ts`) + expect(transformed?.code).toContain('__NUXT_SCRIPT_BUNDLE_') + return { plugin, transformed } +} + +/** + * Drive the plugin through a production-like emit phase: module info comes from a + * synthetic importer graph and patches apply to the emitted chunk code. + */ +async function emit(plugin: any, transformedCode: string, graph: Record) { + const getModuleInfo = (id: string) => { + const queryIndex = id.indexOf('?') + const cleanId = queryIndex === -1 ? id : id.slice(0, queryIndex) + const entry = graph[cleanId] + return entry ? { importers: entry.importers ?? [], dynamicImporters: entry.dynamicImporters ?? [] } : undefined + } + const bundle = { 'entry.js': { type: 'chunk', code: transformedCode } as any } + await plugin.generateBundle.call({ getModuleInfo }, {}, bundle) + return bundle['entry.js'].code as string +} + +describe('nested unreachable components skip their scripts', () => { + it('tracker imported only by unreferenced parent inside the components dir stays unused', async () => { + const { plugin, transformed } = await deferScript() + + const out = await emit(plugin, transformed.code, { + [TRACKER_ID]: { importers: [PARENT_ID] }, + [PARENT_ID]: { importers: [] }, + }) + + expect(fetchMock, 'no third-party download may start').not.toHaveBeenCalled() + expect(out).toContain(TRACKER_SRC) + expect(out).not.toContain('__NUXT_SCRIPT_BUNDLE_') + expect(out).not.toContain('crossorigin') + }) + + it('importer cycles without an exit stay unused', async () => { + const { plugin, transformed } = await deferScript() + + const out = await emit(plugin, transformed.code, { + [TRACKER_ID]: { importers: [PARENT_ID] }, + [PARENT_ID]: { importers: [CYCLE_SIBLING_ID] }, + [CYCLE_SIBLING_ID]: { importers: [PARENT_ID] }, + }) + + expect(fetchMock).not.toHaveBeenCalled() + expect(out).toContain(TRACKER_SRC) + expect(out).not.toContain('crossorigin') + }) + + it('dynamic importers count toward reachability', async () => { + const { plugin, transformed } = await deferScript() + + await emit(plugin, transformed.code, { + [TRACKER_ID]: { dynamicImporters: [PAGE_ID] }, + [PAGE_ID]: { importers: [] }, + }) + + expect(fetchMock).toHaveBeenCalled() + }) +}) diff --git a/test/unit/bundle-placeholder-minification.test.ts b/test/unit/bundle-placeholder-minification.test.ts new file mode 100644 index 000000000..a3b971217 --- /dev/null +++ b/test/unit/bundle-placeholder-minification.test.ts @@ -0,0 +1,79 @@ +// Regression coverage for integrity placeholders surviving production minification: +// rolldown/oxc renders every string literal as a template literal, so any removal +// keyed on exact unminified source text can never match and the unresolved +// `__NUXT_SCRIPT_INTEGRITY_*__` token ships to browser chunks. +import type { AssetBundlerTransformerOptions } from '../../packages/script/src/plugins/transform' +import { describe, expect, it, vi } from 'vitest' +import { NuxtScriptBundleTransformer } from '../../packages/script/src/plugins/transform' + +vi.mock('../../packages/script/src/assets', () => ({ + bundleStorage: vi.fn(() => ({ + getItem: vi.fn(), + setItem: vi.fn(), + getItemRaw: vi.fn(), + setItemRaw: vi.fn(), + hasItem: vi.fn().mockResolvedValue(false), + })), +})) + +const fetchMock = vi.fn() +vi.stubGlobal('fetch', fetchMock) + +const COMPONENT_ID = '/app/node_modules/@nuxt/scripts/dist/runtime/components/BuyWidget.vue' +const COMPONENT_DIR = '/app/node_modules/@nuxt/scripts/dist/runtime/components' +const REMOTE_SRC = 'https://example.com/widget.js' + +function makeNuxt() { + return { + options: { + dev: false, + builder: '@nuxt/vite-builder', + buildDir: '.nuxt', + app: { baseURL: '/' }, + runtimeConfig: { app: {} }, + }, + hooks: { hook: vi.fn() }, + } as any +} + +async function deferAndMinify(options?: Partial, graph: Record | undefined = undefined) { + const code = `const instance = useScript('${REMOTE_SRC}', { bundle: true })` + const plugin = NuxtScriptBundleTransformer({ + renderedScript: new Map(), + integrity: true, + fallbackOnSrcOnBundleFail: true, + componentDir: COMPONENT_DIR, + ...options, + nuxt: makeNuxt(), + }).vite() as any + + const transformed = await plugin.transform.handler.call({}, code, `${COMPONENT_ID}?vue&type=script&setup=true&lang.ts`) + expect(transformed?.code).toBeTruthy() + + // Simulate the oxc minifier shape observed in real emitted chunks: whitespace + // squeezed out and every string literal re-quoted with backticks. + const minified = transformed.code.replace(/\s+/g, '').replace(/'/g, '`') + + const getModuleInfo = (id: string) => (graph?.[id] ? { importers: [], dynamicImporters: [] } : undefined) + const bundle = { 'entry.js': { type: 'chunk', code: minified } as any } + await plugin.generateBundle.call({ getModuleInfo }, {}, bundle) + return bundle['entry.js'].code as string +} + +describe('integrity placeholders under minified rendering', () => { + it('unused component leaves no integrity token or crossorigin in the chunk', async () => { + const code = await deferAndMinify() + + expect(code).toContain(REMOTE_SRC) + expect(code).not.toContain('__NUXT_SCRIPT_INTEGRITY_') + expect(code).not.toContain('crossorigin') + }) + + it('fallback bundle leaves no integrity token or crossorigin in the chunk', async () => { + const imported = await deferAndMinify(undefined, { [COMPONENT_ID]: {} }) + + expect(imported).toContain(REMOTE_SRC) + expect(imported).not.toContain('__NUXT_SCRIPT_INTEGRITY_') + expect(imported).not.toContain('crossorigin') + }) +}) diff --git a/test/unit/render-start-concurrent-downloads.test.ts b/test/unit/render-start-concurrent-downloads.test.ts index b72179778..c41da5828 100644 --- a/test/unit/render-start-concurrent-downloads.test.ts +++ b/test/unit/render-start-concurrent-downloads.test.ts @@ -1,6 +1,7 @@ -// Regression: deferred component bundle downloads in renderStart must overlap. -// A sequential `for...of` + `await` makes the build wall-clock time the sum of -// every used component's script latency instead of the slowest one. +// Regression: deferred component bundle downloads must overlap while they resolve +// during output generation. A sequential `for...of` + `await` makes the build +// wall-clock time the sum of every used component's script latency instead of the +// slowest one. import type { AssetBundlerTransformerOptions } from '../../packages/script/src/plugins/transform' import { describe, expect, it, vi } from 'vitest' import { NuxtScriptBundleTransformer } from '../../packages/script/src/plugins/transform' @@ -20,6 +21,7 @@ const fetchMock = vi.fn() vi.stubGlobal('fetch', fetchMock) const COMPONENT_DIR = '/app/components' +const APP_IMPORTER = '/app/pages/index.vue' function makeNuxt() { return { @@ -54,7 +56,20 @@ async function registerComponent(plugin: any, file: string, src: string) { return transformed.code } -describe('renderStart concurrent deferred downloads', () => { +/** + * Run the deferred pipeline the way the bundler does at emit time: an awaited hook + * with the final module graph, then patches applied to every emitted chunk. + */ +async function emitChunks(plugin: any, codes: Record) { + const getModuleInfo = () => ({ importers: [APP_IMPORTER], dynamicImporters: [] }) + const bundle = Object.fromEntries( + Object.entries(codes).map(([name, code]) => [name, { type: 'chunk', code } as any]), + ) + await plugin.generateBundle.call({ getModuleInfo }, {}, bundle) + return bundle as Record +} + +describe('deferred component downloads stay concurrent', () => { it('starts every used-component download before the first one resolves', async () => { const calls: string[] = [] const gates: Array<() => void> = [] @@ -77,9 +92,9 @@ describe('renderStart concurrent deferred downloads', () => { await registerComponent(plugin, 'Alpha.vue', 'https://example.com/alpha.js') await registerComponent(plugin, 'Beta.vue', 'https://example.com/beta.js') - const running = plugin.renderStart.call({ - getModuleInfo: () => ({ importers: ['entry'], dynamicImporters: [] }), - }) + const running = plugin.generateBundle.call({ + getModuleInfo: () => ({ importers: [APP_IMPORTER], dynamicImporters: [] }), + }, {}, {}) expect(running).toBeInstanceOf(Promise) // First download started; the response is still pending. @@ -108,19 +123,17 @@ describe('renderStart concurrent deferred downloads', () => { const codeA = await registerComponent(plugin, 'Alpha.vue', 'https://example.com/alpha.js') const codeB = await registerComponent(plugin, 'Beta.vue', 'https://example.com/beta.js') - await plugin.renderStart.call({ - getModuleInfo: () => ({ importers: ['entry'], dynamicImporters: [] }), - }) + const bundle = await emitChunks(plugin, { 'chunk-alpha.js': codeA, 'chunk-beta.js': codeB }) - for (const [code, original] of [[codeA, 'alpha'], [codeB, 'beta']] as const) { - const chunk = await plugin.renderChunk.call({}, code, { fileName: `chunk-${original}.js` }) - expect(chunk?.code).toMatch(/\/_scripts\/assets\/[a-f0-9]{16}\.js/) - expect(chunk?.code).not.toContain('__NUXT_SCRIPT_BUNDLE_') - expect(chunk?.code).not.toContain('https://example.com') + for (const fileName of ['chunk-alpha.js', 'chunk-beta.js'] as const) { + const code = bundle[fileName]!.code + expect(code).toMatch(/\/_scripts\/assets\/[a-f0-9]{16}\.js/) + expect(code).not.toContain('__NUXT_SCRIPT_BUNDLE_') + expect(code).not.toContain('https://example.com') } }) - it('fatal download failure still rejects renderStart when fallback is disabled', async () => { + it('fatal download failure still rejects generateBundle when fallback is disabled', async () => { fetchMock.mockImplementation((url: string) => { if (url.includes('broken')) { return Promise.resolve({ ok: false, status: 500, headers: { get: () => null }, _data: undefined, arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)) }) @@ -136,8 +149,8 @@ describe('renderStart concurrent deferred downloads', () => { await registerComponent(plugin, 'Good.vue', 'https://example.com/good.js') await registerComponent(plugin, 'Broken.vue', 'https://example.com/broken.js') - await expect(plugin.renderStart.call({ - getModuleInfo: () => ({ importers: ['entry'], dynamicImporters: [] }), - })).rejects.toThrow(/broken\.js/) + await expect(plugin.generateBundle.call({ + getModuleInfo: () => ({ importers: [APP_IMPORTER], dynamicImporters: [] }), + }, {}, {})).rejects.toThrow(/broken\.js/) }) }) From ece09f12737b9b9a3facf1ce84755143303e701a Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Thu, 27 Aug 2026 16:16:14 +1000 Subject: [PATCH 06/10] fix(transform): keep applying placeholder replacements across watch rebuild emissions --- packages/script/src/plugins/transform.ts | 66 +++++++++++-------- .../render-start-concurrent-downloads.test.ts | 28 ++++++++ 2 files changed, 66 insertions(+), 28 deletions(-) diff --git a/packages/script/src/plugins/transform.ts b/packages/script/src/plugins/transform.ts index ae11c7a01..90dc58f4e 100644 --- a/packages/script/src/plugins/transform.ts +++ b/packages/script/src/plugins/transform.ts @@ -397,37 +397,47 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti const outputHooks: Pick = { async generateBundle(_outputOptions, bundle) { - if (pendingComponentBundles.length === 0) - return + // Watch rebuilds re-emit chunks from cached transformed modules whose code still + // carries placeholder tokens, while replacements persist across emissions. So + // resolution and patching are independent steps: resolve a consumed snapshot of + // the pendings when one exists, then always re-patch emitted chunks whenever we + // hold any replacement. + if (pendingComponentBundles.length > 0) { + // Splice before awaiting: transforms still running while these downloads + // resolve must not have their freshly registered pendings wiped here. + const batch = pendingComponentBundles.splice(0, pendingComponentBundles.length) + + // Bundling has finished handing us the final module graph here and the bundler + // awaits this hook before writing files, so classification, downloads and patching + // land in a single deterministic point. An awaited renderStart cannot do this job: + // rolldown renders chunks without waiting for it, which shipped unresolved + // placeholders whenever a download outlived rendering. + await Promise.all(batch.map(async (pending) => { + const isUnusedComponent = !reachesOutsideComponentDir(pending.componentId, id => this.getModuleInfo(id)) + + if (isUnusedComponent) { + replacements.set(pending.placeholderUrl, pending.downloadOptions.src) + if (pending.placeholderIntegrity) + replacements.set(integrityPlaceholderRemoval(pending.placeholderIntegrity), '') + return + } - // Bundling has finished handing us the final module graph here and the bundler - // awaits this hook before writing files, so classification, downloads and patching - // land in a single deterministic point. An awaited renderStart cannot do this job: - // rolldown renders chunks without waiting for it, which shipped unresolved - // placeholders whenever a download outlived rendering. - await Promise.all(pendingComponentBundles.map(async (pending) => { - const isUnusedComponent = !reachesOutsideComponentDir(pending.componentId, id => this.getModuleInfo(id)) - - if (isUnusedComponent) { - replacements.set(pending.placeholderUrl, pending.downloadOptions.src) - if (pending.placeholderIntegrity) - replacements.set(integrityPlaceholderRemoval(pending.placeholderIntegrity), '') - return - } + // Downloads overlap across pendings: resolveScriptBundle rethrows fatal failures + // (only falling back when explicitly configured), and Promise.all preserves that. + const result = await resolveScriptBundle(pending.downloadOptions, renderedScript, options) - // Downloads overlap across pendings: resolveScriptBundle rethrows fatal failures - // (only falling back when explicitly configured), and Promise.all preserves that. - const result = await resolveScriptBundle(pending.downloadOptions, renderedScript, options) + replacements.set(pending.placeholderUrl, result.url) + if (pending.placeholderIntegrity) { + replacements.set( + result.integrity ? pending.placeholderIntegrity : integrityPlaceholderRemoval(pending.placeholderIntegrity), + result.integrity ?? '', + ) + } + })) + } - replacements.set(pending.placeholderUrl, result.url) - if (pending.placeholderIntegrity) { - replacements.set( - result.integrity ? pending.placeholderIntegrity : integrityPlaceholderRemoval(pending.placeholderIntegrity), - result.integrity ?? '', - ) - } - })) - pendingComponentBundles.length = 0 + if (replacements.size === 0) + return // Mutating `bundle` entries is honored on write (rollup contract); renderChunk-based // patching was not: rolldown may render before any map entry existed. diff --git a/test/unit/render-start-concurrent-downloads.test.ts b/test/unit/render-start-concurrent-downloads.test.ts index c41da5828..e2455b90c 100644 --- a/test/unit/render-start-concurrent-downloads.test.ts +++ b/test/unit/render-start-concurrent-downloads.test.ts @@ -133,6 +133,34 @@ describe('deferred component downloads stay concurrent', () => { } }) + // Regression: during `nuxt build --watch` the bundler module cache persists, so every + // rebuild re-renders chunk code straight from the transform output, which still holds + // the deferred placeholder tokens. Placeholder resolution must keep applying to those + // later emissions instead of stopping after the first one consumed its pendings. + it('resolves leftover placeholders in re-rendered chunks across watch rebuilds', async () => { + fetchMock.mockImplementation((url: string) => Promise.resolve({ + ok: true, + headers: { get: () => null }, + _data: Buffer.from(`/* ${url} */`), + })) + + const plugin = makePlugin() + const codeA = await registerComponent(plugin, 'Alpha.vue', 'https://example.com/alpha.js') + const codeB = await registerComponent(plugin, 'Beta.vue', 'https://example.com/beta.js') + + // First emission consumes the pendings. + await emitChunks(plugin, { 'chunk-1.js': codeA }) + + // Rebuild: rollup re-renders the cached transformed module into a fresh chunk, + // so the placeholder tokens are back even though no new pendings registered. + const rebuildBundle = await emitChunks(plugin, { 'chunk-1.js': codeB }) + + const code = rebuildBundle['chunk-1.js']!.code + expect(code).toMatch(/\/_scripts\/assets\/[a-f0-9]{16}\.js/) + expect(code).not.toContain('__NUXT_SCRIPT_BUNDLE_') + expect(code).not.toMatch(/__NUXT_SCRIPT_INTEGRITY_[a-f0-9]{16}__/) + }) + it('fatal download failure still rejects generateBundle when fallback is disabled', async () => { fetchMock.mockImplementation((url: string) => { if (url.includes('broken')) { From 48f790fce94d080fe9ed1d6ca09b6061640fb65e Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 1 Sep 2026 13:55:36 +1000 Subject: [PATCH 07/10] fix(transform): resolve deferred placeholders in renderChunk Patching moved out of generateBundle. File names are already fixed there, so an upstream script change shipped new chunk code under an old cache-busted name, and the shifted offsets left the emitted sourcemap wrong. renderChunk runs before the bundler hashes the chunk and can return a map, so both stay honest. Resolution is awaited inside renderChunk too, which is what generateBundle was working around. Component registrations are now keyed by module id and survive every emission: - Reachability is decided against the module graph of the build being emitted, so a watch rebuild that adds or drops an importer no longer reuses the first build's verdict. - A failed build consumes nothing, so the retry resolves the same components instead of shipping unresolved placeholders. - Re-transforming a module replaces its own registrations, retiring the token of a src that changed. Replacements swap the whole quoted literal and serialize the value with JSON.stringify. A registry or user src can hold a quote, a backslash or `${`, and the minifier picks the quote style, so splicing raw text into an existing literal could emit broken JavaScript. Patch patterns are re-instantiated per chunk: chunks render concurrently and a global RegExp carries lastIndex between matches. Edits are collected, sorted and applied without overlaps, which MagicString rejects. The issue-882-used fixture now builds with client sourcemaps so CI exercises the map path. Claude-Session: https://claude.ai/code/session_018cCJ2TMLa1tyT1ttEu1L5v --- packages/script/src/plugins/transform.ts | 206 +++++++++++------- test/e2e/issue-882-used-widget.test.ts | 24 +- test/fixtures/issue-882-used/nuxt.config.ts | 5 + test/unit/bundle-component-integrity.test.ts | 16 +- .../bundle-component-reachability.test.ts | 7 +- test/unit/bundle-deferred-resolution.test.ts | 187 ++++++++++++++++ .../bundle-placeholder-minification.test.ts | 19 +- .../render-start-concurrent-downloads.test.ts | 29 +-- 8 files changed, 377 insertions(+), 116 deletions(-) create mode 100644 test/unit/bundle-deferred-resolution.test.ts diff --git a/packages/script/src/plugins/transform.ts b/packages/script/src/plugins/transform.ts index 90dc58f4e..b391c1b8e 100644 --- a/packages/script/src/plugins/transform.ts +++ b/packages/script/src/plugins/transform.ts @@ -154,6 +154,15 @@ interface PendingComponentBundle { placeholderUrl: string } +/** A chunk patch: every `pattern` match is replaced by `value`. */ +interface PlaceholderPatch { + pattern: RegExp + value: string +} + +// Every placeholder token shares this prefix, so a chunk without it needs no patching. +const PLACEHOLDER_PREFIX = '__NUXT_SCRIPT_' + /** * Dropping an unresolved hash must remove the whole `, integrity: ..., crossorigin: 'anonymous'` * span: replacing only the placeholder would leave `integrity: ''` plus crossorigin, which @@ -168,10 +177,53 @@ function integrityPlaceholderRemoval(placeholderIntegrity: string): RegExp { return new RegExp(`,\\s*integrity\\s*:\\s*["'\`]${token}["'\`]\\s*,\\s*crossorigin\\s*:\\s*["'\`][^"'\`]*["'\`]`, 'g') } +/** + * Match the whole quoted literal, not the bare token. The replacement value is a URL we + * do not control (a registry or user `src` can hold a quote, a backslash or `${`), and the + * minifier picks the quote style, so splicing raw text into an existing literal can emit + * broken or injected JavaScript. Swapping the complete literal lets the value be + * serialized safely with `JSON.stringify`. + */ +function quotedPlaceholder(token: string): RegExp { + return new RegExp(`(["'\`])${escapeRegExp(token)}\\1`, 'g') +} + function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } +/** + * Apply every patch to `code` in a single pass. Matches are collected first, then + * applied in source order, because MagicString throws when two edits overlap and an + * integrity removal can swallow a span another patch also matched. + * + * Each pattern is re-instantiated per call: the patch set is shared, chunks render + * concurrently, and a global RegExp carries `lastIndex` between matches. + */ +function applyPlaceholderPatches(code: string, patches: PlaceholderPatch[]): MagicString | undefined { + const edits: { start: number, end: number, value: string }[] = [] + for (const { pattern, value } of patches) { + for (const match of code.matchAll(new RegExp(pattern.source, pattern.flags))) { + if (match[0].length === 0) + break + edits.push({ start: match.index, end: match.index + match[0].length, value }) + } + } + if (!edits.length) + return + edits.sort((a, b) => a.start - b.start) + let s: MagicString | undefined + let appliedEnd = -1 + for (const edit of edits) { + if (edit.start < appliedEnd) + continue + s ??= new MagicString(code) + s.overwrite(edit.start, edit.end, edit.value) + appliedEnd = edit.end + } + return s +} + async function downloadScript(opts: DownloadScriptOptions, renderedScript: NonNullable, fetchOptions?: FetchOptions, cacheMaxAge?: number): Promise<{ url: string, filename?: string } | undefined> { const { src, url, filename, forceDownload, integrity, proxyRewrites, sdkPatches, skipApiRewrites, neutralizeCanvas, assetsBaseURL } = opts if (src === url || !filename) { @@ -340,8 +392,13 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti }) return createUnplugin(() => { - const pendingComponentBundles: PendingComponentBundle[] = [] - const replacements = new Map() + // Keyed by module id so a rebuild of one module replaces its own registrations + // instead of appending stale duplicates. Entries survive every emission: a watch + // rebuild re-renders cached chunks that still carry the placeholder tokens, and a + // build that failed must be able to resolve the same components again. + const componentBundles = new Map() + let patches: PlaceholderPatch[] = [] + let resolution: Promise | undefined /** * A pending component is unused unless some importer path reaches a module outside @@ -369,87 +426,70 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti return false } - function applyReplacements(code: string): string { - let result: MagicString | undefined - for (const [placeholder, replacement] of replacements) { - if (placeholder instanceof RegExp) { - placeholder.lastIndex = 0 - for (let match = placeholder.exec(code); match; match = placeholder.exec(code)) { - result ??= new MagicString(code) - result.remove(match.index, match.index + match[0].length) - if (match[0].length === 0) - break - } - continue + /** + * Classify every registered component against the final module graph and turn the + * outcome into chunk patches. Reachability is decided fresh on each build: a watch + * rebuild can add or drop the importer that makes a widget used, so a decision cached + * from an earlier build would ship the wrong src. + */ + async function resolvePendingBundles(getModuleInfo: (id: string) => { importers?: string[], dynamicImporters?: string[] } | undefined | null): Promise { + const pendings = [...componentBundles.values()].flat() + const resolved: PlaceholderPatch[] = [] + // Downloads overlap across pendings: resolveScriptBundle rethrows fatal failures + // (only falling back when explicitly configured), and Promise.all preserves that. + await Promise.all(pendings.map(async (pending) => { + if (!reachesOutsideComponentDir(pending.componentId, getModuleInfo)) { + resolved.push({ pattern: quotedPlaceholder(pending.placeholderUrl), value: JSON.stringify(pending.downloadOptions.src) }) + if (pending.placeholderIntegrity) + resolved.push({ pattern: integrityPlaceholderRemoval(pending.placeholderIntegrity), value: '' }) + return } - let offset = 0 - while (offset < code.length) { - const index = code.indexOf(placeholder, offset) - if (index === -1) - break - result ??= new MagicString(code) - result.overwrite(index, index + placeholder.length, replacement) - offset = index + placeholder.length + + const result = await resolveScriptBundle(pending.downloadOptions, renderedScript, options) + + resolved.push({ pattern: quotedPlaceholder(pending.placeholderUrl), value: JSON.stringify(result.url) }) + if (pending.placeholderIntegrity) { + resolved.push(result.integrity + ? { pattern: quotedPlaceholder(pending.placeholderIntegrity), value: JSON.stringify(result.integrity) } + : { pattern: integrityPlaceholderRemoval(pending.placeholderIntegrity), value: '' }) } - } - return result ? result.toString() : code + })) + // Publish only once every download settled, so a rejected build never leaves a + // half-built patch set behind for the next emission to apply. + patches = resolved } - const outputHooks: Pick = { - async generateBundle(_outputOptions, bundle) { - // Watch rebuilds re-emit chunks from cached transformed modules whose code still - // carries placeholder tokens, while replacements persist across emissions. So - // resolution and patching are independent steps: resolve a consumed snapshot of - // the pendings when one exists, then always re-patch emitted chunks whenever we - // hold any replacement. - if (pendingComponentBundles.length > 0) { - // Splice before awaiting: transforms still running while these downloads - // resolve must not have their freshly registered pendings wiped here. - const batch = pendingComponentBundles.splice(0, pendingComponentBundles.length) - - // Bundling has finished handing us the final module graph here and the bundler - // awaits this hook before writing files, so classification, downloads and patching - // land in a single deterministic point. An awaited renderStart cannot do this job: - // rolldown renders chunks without waiting for it, which shipped unresolved - // placeholders whenever a download outlived rendering. - await Promise.all(batch.map(async (pending) => { - const isUnusedComponent = !reachesOutsideComponentDir(pending.componentId, id => this.getModuleInfo(id)) - - if (isUnusedComponent) { - replacements.set(pending.placeholderUrl, pending.downloadOptions.src) - if (pending.placeholderIntegrity) - replacements.set(integrityPlaceholderRemoval(pending.placeholderIntegrity), '') - return - } - - // Downloads overlap across pendings: resolveScriptBundle rethrows fatal failures - // (only falling back when explicitly configured), and Promise.all preserves that. - const result = await resolveScriptBundle(pending.downloadOptions, renderedScript, options) + /** + * Patching in `renderChunk` keeps the chunk content hash and the sourcemap honest: + * the bundler computes both after this hook. `generateBundle` is too late, since file + * names are already fixed there, so an upstream script change would ship new code + * under an old cache-busted name. + * + * Resolution is awaited here rather than only in `renderStart` because rolldown can + * render chunks before an awaited `renderStart` settles, which shipped unresolved + * placeholders whenever a download outlived rendering. + */ + const outputHooks: Pick = { + async renderStart() { + resolution = resolvePendingBundles(id => this.getModuleInfo(id)) + await resolution + }, - replacements.set(pending.placeholderUrl, result.url) - if (pending.placeholderIntegrity) { - replacements.set( - result.integrity ? pending.placeholderIntegrity : integrityPlaceholderRemoval(pending.placeholderIntegrity), - result.integrity ?? '', - ) - } - })) - } + async renderChunk(code, chunk, outputOptions) { + resolution ??= resolvePendingBundles(id => this.getModuleInfo(id)) + await resolution - if (replacements.size === 0) + if (!patches.length || !code.includes(PLACEHOLDER_PREFIX)) return - - // Mutating `bundle` entries is honored on write (rollup contract); renderChunk-based - // patching was not: rolldown may render before any map entry existed. - for (const file of Object.values(bundle)) { - if (file.type !== 'chunk') - continue - const patched = applyReplacements(file.code) - if (patched !== file.code) { - // Edits only touch token spans inserted at transform time, so the existing - // sourcemap stays usable; regenerating one here would lose all chunk mappings. - file.code = patched - } + const s = applyPlaceholderPatches(code, patches) + if (!s) + return + // A replacement rarely matches the length of its token, so every later mapping in + // the chunk shifts. Hand the bundler a map of the edit instead of letting it keep + // the pre-patch offsets. + return { + code: s.toString(), + map: outputOptions.sourcemap ? s.generateMap({ hires: 'boundary', source: chunk.fileName }) as SourceMapInput : undefined, } }, } @@ -474,6 +514,11 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti const s = new MagicString(code) const deferredOps: (() => Promise)[] = [] + // Registrations for this module, replacing any from an earlier build. A changed + // `src` retires its old token, so a stale entry can never download a script the + // module no longer asks for. + const registered: PendingComponentBundle[] = [] + componentBundles.delete(id) parseAndWalk(code, id, (_node) => { const calleeName = (_node as any).callee?.name if (!calleeName) @@ -776,9 +821,9 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti // graph so unused widgets do not trigger third-party downloads. if (componentId) { const token = createHash('sha256').update(`${id}:${node.start}:${src}`).digest('hex').slice(0, 16) - const placeholderUrl = `__NUXT_SCRIPT_BUNDLE_${token}__` - const placeholderIntegrity = options.integrity ? `__NUXT_SCRIPT_INTEGRITY_${token}__` : undefined - pendingComponentBundles.push({ componentId, downloadOptions, placeholderIntegrity, placeholderUrl }) + const placeholderUrl = `${PLACEHOLDER_PREFIX}BUNDLE_${token}__` + const placeholderIntegrity = options.integrity ? `${PLACEHOLDER_PREFIX}INTEGRITY_${token}__` : undefined + registered.push({ componentId, downloadOptions, placeholderIntegrity, placeholderUrl }) deferredOps.push(async () => rewriteScriptCall(placeholderUrl, placeholderIntegrity)) } else { @@ -797,6 +842,9 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti await op() } + if (registered.length) + componentBundles.set(id, registered) + if (s.hasChanged()) { return { code: s.toString(), diff --git a/test/e2e/issue-882-used-widget.test.ts b/test/e2e/issue-882-used-widget.test.ts index 49e4b1d4e..ea8da8de4 100644 --- a/test/e2e/issue-882-used-widget.test.ts +++ b/test/e2e/issue-882-used-widget.test.ts @@ -30,7 +30,7 @@ function attrValueRe(name: string, value: string): RegExp { * dynamically loaded island chunk is reachable by walking module specifiers * over HTTP — so read the files Nitro copied into its public dir directly. */ -async function readClientChunks(): Promise { +async function readClientChunks(): Promise<{ name: string, dir: string, code: string }[]> { const ctx = useTestContext() const nitroOutputDir = ctx.nuxt ? ctx.nuxt.options.nitro.output.dir @@ -39,7 +39,11 @@ async function readClientChunks(): Promise { const clientChunkDir = join(nitroOutputDir!, 'public', '_nuxt') const entries = await readdir(clientChunkDir) return Promise.all( - entries.filter(name => name.endsWith('.js')).map(name => readFile(join(clientChunkDir, name), 'utf-8')), + entries.filter(name => name.endsWith('.js')).map(async name => ({ + name, + dir: clientChunkDir, + code: await readFile(join(clientChunkDir, name), 'utf-8'), + })), ) } @@ -63,15 +67,21 @@ describe('used script widget (deferred component path)', () => { // chunk that drives the runtime script injection. const clientChunks = await readClientChunks() expect(clientChunks.length, 'expected built client chunks on disk').toBeGreaterThan(0) - const widgetChunk = clientChunks.find(code => code.includes(assetUrl!)) + const widgetChunk = clientChunks.find(chunk => chunk.code.includes(assetUrl!)) expect(widgetChunk, 'expected a built client chunk referencing the bundled asset').toBeTruthy() - expect(widgetChunk!).toMatch(attrValueRe('integrity', expectedIntegrity)) - expect(widgetChunk!).toMatch(attrValueRe('crossorigin', 'anonymous')) + expect(widgetChunk!.code).toMatch(attrValueRe('integrity', expectedIntegrity)) + expect(widgetChunk!.code).toMatch(attrValueRe('crossorigin', 'anonymous')) + + // The fixture builds with client sourcemaps on. Rewriting the chunk shifts every + // later offset, so the rewrite must hand the bundler a map rather than let it keep + // the pre-patch one. + const map = JSON.parse(await readFile(join(widgetChunk!.dir, `${widgetChunk!.name}.map`), 'utf-8')) + expect(map.mappings, 'the rewritten chunk must still ship a populated sourcemap').toBeTruthy() // Unused auto-registered widgets fall back to their remote src; their unresolved // integrity placeholders must not ship into any production chunk either. - for (const code of clientChunks) { - expect(code, 'a client chunk still contains a placeholder token').not.toContain('__NUXT_SCRIPT_') + for (const chunk of clientChunks) { + expect(chunk.code, 'a client chunk still contains a placeholder token').not.toContain('__NUXT_SCRIPT_') } }) }) diff --git a/test/fixtures/issue-882-used/nuxt.config.ts b/test/fixtures/issue-882-used/nuxt.config.ts index 1d9f38d57..9fa15cca6 100644 --- a/test/fixtures/issue-882-used/nuxt.config.ts +++ b/test/fixtures/issue-882-used/nuxt.config.ts @@ -12,5 +12,10 @@ export default defineNuxtConfig({ selectiveClient: true, }, }, + // Placeholder resolution rewrites emitted chunks, so build with client sourcemaps on + // to prove the rewrite hands the bundler a usable map instead of stale offsets. + sourcemap: { + client: true, + }, compatibilityDate: '2024-07-05', }) diff --git a/test/unit/bundle-component-integrity.test.ts b/test/unit/bundle-component-integrity.test.ts index 4782318ca..9e755bbcb 100644 --- a/test/unit/bundle-component-integrity.test.ts +++ b/test/unit/bundle-component-integrity.test.ts @@ -68,11 +68,11 @@ async function buildComponentChunk(options: Partial ({ importers, dynamicImporters: [] }) - const bundle = { 'entry.js': { type: 'chunk', code: transformed.code } as any } - await plugin.generateBundle.call({ getModuleInfo }, {}, bundle) - return bundle['entry.js'].code as string + // Simulate rollup's final module graph before chunk hashes are computed. + const ctx = { getModuleInfo: () => ({ importers, dynamicImporters: [] }) } + await plugin.renderStart.call(ctx, {}, {}) + const result = await plugin.renderChunk.call(ctx, transformed.code, { fileName: 'entry.js' }, { sourcemap: false }) + return (result?.code ?? transformed.code) as string } describe('deferred component bundling integrity placeholders', () => { @@ -105,8 +105,10 @@ describe('deferred component bundling integrity placeholders', () => { const code = await buildComponentChunk({}, [APP_IMPORTER]) const expected = `sha384-${createHash('sha384').update(body).digest('base64')}` + const escaped = expected.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') expect(code).toMatch(/\/_scripts\/assets\/[a-f0-9]{16}\.js/) - expect(code).toContain(`integrity: '${expected}'`) - expect(code).toContain(`crossorigin: 'anonymous'`) + // The bundler picks the quote style for every emitted literal, so assert on the value. + expect(code).toMatch(new RegExp(`integrity:\\s*["'\`]${escaped}["'\`]`)) + expect(code).toMatch(/crossorigin:\s*["'`]anonymous["'`]/) }) }) diff --git a/test/unit/bundle-component-reachability.test.ts b/test/unit/bundle-component-reachability.test.ts index 3bb1f4764..2751fc4c0 100644 --- a/test/unit/bundle-component-reachability.test.ts +++ b/test/unit/bundle-component-reachability.test.ts @@ -65,9 +65,10 @@ async function emit(plugin: any, transformedCode: string, graph: Record { diff --git a/test/unit/bundle-deferred-resolution.test.ts b/test/unit/bundle-deferred-resolution.test.ts new file mode 100644 index 000000000..643410ad4 --- /dev/null +++ b/test/unit/bundle-deferred-resolution.test.ts @@ -0,0 +1,187 @@ +// Regression coverage for the deferred component bundling contract: +// - placeholders are patched while the bundler can still hash and map the chunk +// - replacement values are serialized, never spliced into an existing literal +// - reachability is decided against the module graph of the build being emitted +// - a build that failed leaves its registrations intact for the next attempt +import type { AssetBundlerTransformerOptions } from '../../packages/script/src/plugins/transform' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { NuxtScriptBundleTransformer } from '../../packages/script/src/plugins/transform' + +const mockBundleStorage: any = { + getItem: vi.fn(), + setItem: vi.fn(), + getItemRaw: vi.fn(), + setItemRaw: vi.fn(), + hasItem: vi.fn().mockResolvedValue(false), +} +vi.mock('../../packages/script/src/assets', () => ({ + bundleStorage: vi.fn(() => mockBundleStorage), +})) + +const fetchMock = vi.fn() +vi.stubGlobal('fetch', fetchMock) + +const COMPONENT_DIR = '/app/node_modules/@nuxt/scripts/dist/runtime/components' +const APP_IMPORTER = '/app/pages/index.vue' + +function makeNuxt() { + return { + options: { + dev: false, + builder: '@nuxt/vite-builder', + buildDir: '.nuxt', + app: { baseURL: '/' }, + runtimeConfig: { app: {} }, + }, + hooks: { hook: vi.fn() }, + } as any +} + +function makePlugin(options: Partial = {}) { + return NuxtScriptBundleTransformer({ + renderedScript: new Map(), + fallbackOnSrcOnBundleFail: true, + componentDir: COMPONENT_DIR, + ...options, + nuxt: makeNuxt(), + }).vite() as any +} + +async function registerComponent(plugin: any, file: string, src: string): Promise { + const code = `const instance = useScript(${JSON.stringify(src)}, { bundle: true })` + const transformed = await plugin.transform.handler.call({}, code, `${COMPONENT_DIR}/${file}?vue&type=script&setup=true&lang.ts`) + expect(transformed?.code, 'the component must register a deferred placeholder').toContain('__NUXT_SCRIPT_BUNDLE_') + return transformed.code as string +} + +function makeContext(importers: string[]) { + return { getModuleInfo: () => ({ importers, dynamicImporters: [] }) } +} + +async function render(plugin: any, code: string, importers: string[], outputOptions: any = { sourcemap: false }) { + const ctx = makeContext(importers) + await plugin.renderStart.call(ctx, {}, {}) + return plugin.renderChunk.call(ctx, code, { fileName: 'entry.js' }, outputOptions) +} + +function mockDownload(body = '/* widget */') { + fetchMock.mockImplementation(() => Promise.resolve({ + ok: true, + headers: { get: () => null }, + _data: Buffer.from(body), + })) +} + +/** Execute the emitted chunk with a stubbed `useScript` and return its first argument. */ +function evaluateScriptArg(code: string): any { + const calls: any[] = [] + // eslint-disable-next-line no-new-func + const run = new Function('useScript', code) + run((...args: any[]) => calls.push(args)) + expect(calls, 'the emitted chunk must still call useScript once').toHaveLength(1) + return calls[0]![0] +} + +describe('deferred component bundle resolution', () => { + beforeEach(() => { + fetchMock.mockReset() + }) + + it('patches the chunk in renderChunk so the bundler can still hash and map it', async () => { + mockDownload() + const plugin = makePlugin() + const code = await registerComponent(plugin, 'Alpha.vue', 'https://example.com/alpha.js') + + // generateBundle runs after file names are fixed, so patching there would ship new + // code under a stale cache-busted name and an unshifted sourcemap. + expect(plugin.generateBundle).toBeUndefined() + + const result = await render(plugin, code, [APP_IMPORTER], { sourcemap: true }) + + expect(result.code).toMatch(/\/_scripts\/assets\/[a-f0-9]{16}\.js/) + expect(result.code).not.toContain('__NUXT_SCRIPT_') + expect(result.map?.mappings, 'a rewritten chunk must carry a sourcemap for the shifted offsets').toBeTruthy() + }) + + it('leaves an untouched chunk alone', async () => { + mockDownload() + const plugin = makePlugin() + await registerComponent(plugin, 'Alpha.vue', 'https://example.com/alpha.js') + + const result = await render(plugin, 'export const unrelated = 1', [APP_IMPORTER], { sourcemap: true }) + + expect(result, 'a chunk with no placeholder must not be rewritten').toBeFalsy() + }) + + it('serializes an unused component src that carries quote characters', async () => { + // eslint-disable-next-line no-template-curly-in-string -- a literal `${` in the src is the point + const src = 'https://example.com/w.js?a=\'b&c="d&e=`f&g=${h}' + const plugin = makePlugin() + const code = await registerComponent(plugin, 'Quoted.vue', src) + + // No importer leaves the components dir, so the widget keeps its remote src. + const result = await render(plugin, code, []) + + expect(fetchMock, 'an unused widget must not download').not.toHaveBeenCalled() + expect(evaluateScriptArg(result.code)).toBe(src) + }) + + it('serializes into a chunk the minifier re-quoted with template literals', async () => { + // eslint-disable-next-line no-template-curly-in-string -- a literal `${` in the src is the point + const src = 'https://example.com/w.js?a=`b&c=${d}' + const plugin = makePlugin() + const code = await registerComponent(plugin, 'Quoted.vue', src) + // oxc/rolldown normalize every string literal to a template literal. + const minified = code.replace(/'/g, '`') + + const result = await render(plugin, minified, []) + + expect(evaluateScriptArg(result.code)).toBe(src) + }) + + it('re-decides reachability on every build instead of reusing the first verdict', async () => { + mockDownload() + const plugin = makePlugin() + const code = await registerComponent(plugin, 'Alpha.vue', 'https://example.com/alpha.js') + + const unused = await render(plugin, code, []) + expect(unused.code).toContain('https://example.com/alpha.js') + expect(fetchMock).not.toHaveBeenCalled() + + // A watch rebuild adds the page that uses the widget: the same chunk must now + // resolve to the bundled asset rather than the verdict cached from build one. + const used = await render(plugin, code, [APP_IMPORTER]) + expect(used.code).toMatch(/\/_scripts\/assets\/[a-f0-9]{16}\.js/) + expect(used.code).not.toContain('https://example.com/alpha.js') + }) + + it('keeps registrations after a failed build so the retry resolves them', async () => { + fetchMock.mockImplementation(() => Promise.resolve({ + ok: false, + status: 500, + headers: { get: () => null }, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + })) + const plugin = makePlugin({ fallbackOnSrcOnBundleFail: false }) + const code = await registerComponent(plugin, 'Alpha.vue', 'https://example.com/alpha.js') + + await expect(render(plugin, code, [APP_IMPORTER])).rejects.toThrow(/alpha\.js/) + + mockDownload() + const retry = await render(plugin, code, [APP_IMPORTER]) + + expect(retry.code).toMatch(/\/_scripts\/assets\/[a-f0-9]{16}\.js/) + expect(retry.code).not.toContain('__NUXT_SCRIPT_') + }) + + it('drops a stale registration when the component changes its src', async () => { + mockDownload() + const plugin = makePlugin() + await registerComponent(plugin, 'Alpha.vue', 'https://example.com/old.js') + const code = await registerComponent(plugin, 'Alpha.vue', 'https://example.com/new.js') + + await render(plugin, code, [APP_IMPORTER]) + + expect(fetchMock.mock.calls.map(call => call[0])).toEqual(['https://example.com/new.js']) + }) +}) diff --git a/test/unit/bundle-placeholder-minification.test.ts b/test/unit/bundle-placeholder-minification.test.ts index a3b971217..34ff9203d 100644 --- a/test/unit/bundle-placeholder-minification.test.ts +++ b/test/unit/bundle-placeholder-minification.test.ts @@ -22,6 +22,7 @@ vi.stubGlobal('fetch', fetchMock) const COMPONENT_ID = '/app/node_modules/@nuxt/scripts/dist/runtime/components/BuyWidget.vue' const COMPONENT_DIR = '/app/node_modules/@nuxt/scripts/dist/runtime/components' const REMOTE_SRC = 'https://example.com/widget.js' +const APP_IMPORTER = '/app/pages/index.vue' function makeNuxt() { return { @@ -36,7 +37,7 @@ function makeNuxt() { } as any } -async function deferAndMinify(options?: Partial, graph: Record | undefined = undefined) { +async function deferAndMinify(options?: Partial, importers: string[] = []) { const code = `const instance = useScript('${REMOTE_SRC}', { bundle: true })` const plugin = NuxtScriptBundleTransformer({ renderedScript: new Map(), @@ -54,10 +55,10 @@ async function deferAndMinify(options?: Partial, // squeezed out and every string literal re-quoted with backticks. const minified = transformed.code.replace(/\s+/g, '').replace(/'/g, '`') - const getModuleInfo = (id: string) => (graph?.[id] ? { importers: [], dynamicImporters: [] } : undefined) - const bundle = { 'entry.js': { type: 'chunk', code: minified } as any } - await plugin.generateBundle.call({ getModuleInfo }, {}, bundle) - return bundle['entry.js'].code as string + const ctx = { getModuleInfo: (id: string) => (id === COMPONENT_ID ? { importers, dynamicImporters: [] } : { importers: [], dynamicImporters: [] }) } + await plugin.renderStart.call(ctx, {}, {}) + const result = await plugin.renderChunk.call(ctx, minified, { fileName: 'entry.js' }, { sourcemap: false }) + return (result?.code ?? minified) as string } describe('integrity placeholders under minified rendering', () => { @@ -69,9 +70,13 @@ describe('integrity placeholders under minified rendering', () => { expect(code).not.toContain('crossorigin') }) - it('fallback bundle leaves no integrity token or crossorigin in the chunk', async () => { - const imported = await deferAndMinify(undefined, { [COMPONENT_ID]: {} }) + it('used component whose download fails falls back to the remote src without crossorigin', async () => { + // A used component takes the download path; the failed fetch resolves no integrity + // hash, so the whole integrity + crossorigin span has to go. + fetchMock.mockRejectedValue(new Error('network down')) + const imported = await deferAndMinify(undefined, [APP_IMPORTER]) + expect(fetchMock, 'the used component must attempt its download').toHaveBeenCalled() expect(imported).toContain(REMOTE_SRC) expect(imported).not.toContain('__NUXT_SCRIPT_INTEGRITY_') expect(imported).not.toContain('crossorigin') diff --git a/test/unit/render-start-concurrent-downloads.test.ts b/test/unit/render-start-concurrent-downloads.test.ts index e2455b90c..06f66c4fb 100644 --- a/test/unit/render-start-concurrent-downloads.test.ts +++ b/test/unit/render-start-concurrent-downloads.test.ts @@ -57,16 +57,19 @@ async function registerComponent(plugin: any, file: string, src: string) { } /** - * Run the deferred pipeline the way the bundler does at emit time: an awaited hook - * with the final module graph, then patches applied to every emitted chunk. + * Run the deferred pipeline the way the bundler does while rendering: an awaited + * `renderStart` with the final module graph, then `renderChunk` per emitted chunk. + * Patching before the chunk hash is computed is what keeps cache-busting names honest. */ -async function emitChunks(plugin: any, codes: Record) { - const getModuleInfo = () => ({ importers: [APP_IMPORTER], dynamicImporters: [] }) - const bundle = Object.fromEntries( - Object.entries(codes).map(([name, code]) => [name, { type: 'chunk', code } as any]), - ) - await plugin.generateBundle.call({ getModuleInfo }, {}, bundle) - return bundle as Record +async function emitChunks(plugin: any, codes: Record, importers: string[] = [APP_IMPORTER]) { + const ctx = { getModuleInfo: () => ({ importers, dynamicImporters: [] }) } + await plugin.renderStart.call(ctx, {}, {}) + const out: Record = {} + for (const [name, code] of Object.entries(codes)) { + const result = await plugin.renderChunk.call(ctx, code, { fileName: name }, { sourcemap: false }) + out[name] = (result?.code ?? code) as string + } + return out } describe('deferred component downloads stay concurrent', () => { @@ -92,7 +95,7 @@ describe('deferred component downloads stay concurrent', () => { await registerComponent(plugin, 'Alpha.vue', 'https://example.com/alpha.js') await registerComponent(plugin, 'Beta.vue', 'https://example.com/beta.js') - const running = plugin.generateBundle.call({ + const running = plugin.renderStart.call({ getModuleInfo: () => ({ importers: [APP_IMPORTER], dynamicImporters: [] }), }, {}, {}) expect(running).toBeInstanceOf(Promise) @@ -126,7 +129,7 @@ describe('deferred component downloads stay concurrent', () => { const bundle = await emitChunks(plugin, { 'chunk-alpha.js': codeA, 'chunk-beta.js': codeB }) for (const fileName of ['chunk-alpha.js', 'chunk-beta.js'] as const) { - const code = bundle[fileName]!.code + const code = bundle[fileName]! expect(code).toMatch(/\/_scripts\/assets\/[a-f0-9]{16}\.js/) expect(code).not.toContain('__NUXT_SCRIPT_BUNDLE_') expect(code).not.toContain('https://example.com') @@ -155,7 +158,7 @@ describe('deferred component downloads stay concurrent', () => { // so the placeholder tokens are back even though no new pendings registered. const rebuildBundle = await emitChunks(plugin, { 'chunk-1.js': codeB }) - const code = rebuildBundle['chunk-1.js']!.code + const code = rebuildBundle['chunk-1.js']! expect(code).toMatch(/\/_scripts\/assets\/[a-f0-9]{16}\.js/) expect(code).not.toContain('__NUXT_SCRIPT_BUNDLE_') expect(code).not.toMatch(/__NUXT_SCRIPT_INTEGRITY_[a-f0-9]{16}__/) @@ -177,7 +180,7 @@ describe('deferred component downloads stay concurrent', () => { await registerComponent(plugin, 'Good.vue', 'https://example.com/good.js') await registerComponent(plugin, 'Broken.vue', 'https://example.com/broken.js') - await expect(plugin.generateBundle.call({ + await expect(plugin.renderStart.call({ getModuleInfo: () => ({ importers: [APP_IMPORTER], dynamicImporters: [] }), }, {}, {})).rejects.toThrow(/broken\.js/) }) From b9b0189d2d28760bb483dc9364d214590887692a Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 1 Sep 2026 14:07:43 +1000 Subject: [PATCH 08/10] perf(transform): rule out placeholder patches with a token check Every auto-registered widget contributes a patch, and a build with selective client islands registers all of them. Patching one chunk ran a full regex scan per patch, so the cost of a chunk grew with the number of widgets in the app. An indexOf check rules a patch out first, and a chunk holds the tokens of one or two widgets. Measured on a chunk carrying one widget's placeholders, 19 components registered (38 patches): 200 kB chunk 0.65 ms -> 0.08 ms 1 MB chunk 3.03 ms -> 0.48 ms Pattern construction moved into urlPatch and integrityRemovalPatch so a patch cannot be built without the token that guards it. Claude-Session: https://claude.ai/code/session_018cCJ2TMLa1tyT1ttEu1L5v --- packages/script/src/plugins/transform.ts | 36 ++++++++++++++------ test/unit/bundle-deferred-resolution.test.ts | 33 ++++++++++++++++++ 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/packages/script/src/plugins/transform.ts b/packages/script/src/plugins/transform.ts index b391c1b8e..de9396343 100644 --- a/packages/script/src/plugins/transform.ts +++ b/packages/script/src/plugins/transform.ts @@ -156,6 +156,12 @@ interface PendingComponentBundle { /** A chunk patch: every `pattern` match is replaced by `value`. */ interface PlaceholderPatch { + /** + * The literal token `pattern` needs in order to match. Every registered component + * contributes a pattern, so without this the cost of patching one chunk grows with the + * number of widgets in the app. `indexOf` rules a pattern out far faster than running it. + */ + token: string pattern: RegExp value: string } @@ -172,9 +178,13 @@ const PLACEHOLDER_PREFIX = '__NUXT_SCRIPT_' * not ours to choose (oxc renders every literal as a template literal), so match the two * properties structurally instead of comparing exact source text. */ -function integrityPlaceholderRemoval(placeholderIntegrity: string): RegExp { +function integrityRemovalPatch(placeholderIntegrity: string): PlaceholderPatch { const token = escapeRegExp(placeholderIntegrity) - return new RegExp(`,\\s*integrity\\s*:\\s*["'\`]${token}["'\`]\\s*,\\s*crossorigin\\s*:\\s*["'\`][^"'\`]*["'\`]`, 'g') + return { + token: placeholderIntegrity, + pattern: new RegExp(`,\\s*integrity\\s*:\\s*["'\`]${token}["'\`]\\s*,\\s*crossorigin\\s*:\\s*["'\`][^"'\`]*["'\`]`, 'g'), + value: '', + } } /** @@ -184,8 +194,12 @@ function integrityPlaceholderRemoval(placeholderIntegrity: string): RegExp { * broken or injected JavaScript. Swapping the complete literal lets the value be * serialized safely with `JSON.stringify`. */ -function quotedPlaceholder(token: string): RegExp { - return new RegExp(`(["'\`])${escapeRegExp(token)}\\1`, 'g') +function urlPatch(placeholder: string, value: string): PlaceholderPatch { + return { + token: placeholder, + pattern: new RegExp(`(["'\`])${escapeRegExp(placeholder)}\\1`, 'g'), + value: JSON.stringify(value), + } } function escapeRegExp(value: string): string { @@ -202,7 +216,9 @@ function escapeRegExp(value: string): string { */ function applyPlaceholderPatches(code: string, patches: PlaceholderPatch[]): MagicString | undefined { const edits: { start: number, end: number, value: string }[] = [] - for (const { pattern, value } of patches) { + for (const { token, pattern, value } of patches) { + if (!code.includes(token)) + continue for (const match of code.matchAll(new RegExp(pattern.source, pattern.flags))) { if (match[0].length === 0) break @@ -439,19 +455,19 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti // (only falling back when explicitly configured), and Promise.all preserves that. await Promise.all(pendings.map(async (pending) => { if (!reachesOutsideComponentDir(pending.componentId, getModuleInfo)) { - resolved.push({ pattern: quotedPlaceholder(pending.placeholderUrl), value: JSON.stringify(pending.downloadOptions.src) }) + resolved.push(urlPatch(pending.placeholderUrl, pending.downloadOptions.src)) if (pending.placeholderIntegrity) - resolved.push({ pattern: integrityPlaceholderRemoval(pending.placeholderIntegrity), value: '' }) + resolved.push(integrityRemovalPatch(pending.placeholderIntegrity)) return } const result = await resolveScriptBundle(pending.downloadOptions, renderedScript, options) - resolved.push({ pattern: quotedPlaceholder(pending.placeholderUrl), value: JSON.stringify(result.url) }) + resolved.push(urlPatch(pending.placeholderUrl, result.url)) if (pending.placeholderIntegrity) { resolved.push(result.integrity - ? { pattern: quotedPlaceholder(pending.placeholderIntegrity), value: JSON.stringify(result.integrity) } - : { pattern: integrityPlaceholderRemoval(pending.placeholderIntegrity), value: '' }) + ? urlPatch(pending.placeholderIntegrity, result.integrity) + : integrityRemovalPatch(pending.placeholderIntegrity)) } })) // Publish only once every download settled, so a rejected build never leaves a diff --git a/test/unit/bundle-deferred-resolution.test.ts b/test/unit/bundle-deferred-resolution.test.ts index 643410ad4..49c5c2349 100644 --- a/test/unit/bundle-deferred-resolution.test.ts +++ b/test/unit/bundle-deferred-resolution.test.ts @@ -174,6 +174,39 @@ describe('deferred component bundle resolution', () => { expect(retry.code).not.toContain('__NUXT_SCRIPT_') }) + it('patches only the placeholders a chunk actually holds', async () => { + // Every auto-registered widget contributes patches, but a chunk carries the tokens of + // one or two of them. Skipping the rest must not skip one that does match, and must + // not leak another widget's resolved value into this chunk. + mockDownload() + const plugin = makePlugin({ integrity: true }) + const names = ['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo'] + const codes: Record = {} + for (const name of names) + codes[name] = await registerComponent(plugin, `${name}.vue`, `https://example.com/${name.toLowerCase()}.js`) + + // Charlie is used, so it bundles; the rest are unreachable and keep their remote src. + const ctx = { + getModuleInfo: (id: string) => ({ + importers: id === `${COMPONENT_DIR}/Charlie.vue` ? [APP_IMPORTER] : [], + dynamicImporters: [], + }), + } + await plugin.renderStart.call(ctx, {}, {}) + const patched = await plugin.renderChunk.call(ctx, codes.Charlie!, { fileName: 'charlie.js' }, { sourcemap: false }) + + expect(patched.code).toMatch(/\/_scripts\/assets\/[a-f0-9]{16}\.js/) + expect(patched.code).not.toContain('__NUXT_SCRIPT_') + for (const name of names.filter(n => n !== 'Charlie')) + expect(patched.code, `${name} must not bleed into Charlie's chunk`).not.toContain(`${name.toLowerCase()}.js`) + + // A sibling chunk still resolves against the same shared patch set. + const sibling = await plugin.renderChunk.call(ctx, codes.Echo!, { fileName: 'echo.js' }, { sourcemap: false }) + expect(evaluateScriptArg(sibling.code)).toEqual({ src: 'https://example.com/echo.js' }) + expect(sibling.code).not.toContain('__NUXT_SCRIPT_') + expect(sibling.code).not.toContain('crossorigin') + }) + it('drops a stale registration when the component changes its src', async () => { mockDownload() const plugin = makePlugin() From 52e931bdc999f906aab0b2c84dd9c8960c4e9cbb Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 1 Sep 2026 14:26:51 +1000 Subject: [PATCH 09/10] fix(transform): scope placeholder resolution to its build environment Vite builds the client and ssr environments from one config, so the plugin factory runs once and both environments shared `patches` and `resolution`. A chunk read whichever verdict resolved most recently. The environments run serially today, so nothing was miscompiled, but the coupling was silent and a parallel environment build would have swapped one environment's patch set into the other's chunks. Resolution is now keyed by environment name, and `renderChunk` reads its patch set from the promise it awaits rather than from a field another environment can replace mid-render. A surviving placeholder now fails the build. A token that reaches the browser ships as a script `src` or an SRI hash and breaks at runtime, where it is far harder to trace. The error names the chunk, the token, the component and the src behind it. Claude-Session: https://claude.ai/code/session_018cCJ2TMLa1tyT1ttEu1L5v --- packages/script/src/plugins/transform.ts | 61 ++++++++++++++++---- test/unit/bundle-deferred-resolution.test.ts | 40 +++++++++++++ 2 files changed, 89 insertions(+), 12 deletions(-) diff --git a/packages/script/src/plugins/transform.ts b/packages/script/src/plugins/transform.ts index de9396343..8d18e1b09 100644 --- a/packages/script/src/plugins/transform.ts +++ b/packages/script/src/plugins/transform.ts @@ -168,6 +168,7 @@ interface PlaceholderPatch { // Every placeholder token shares this prefix, so a chunk without it needs no patching. const PLACEHOLDER_PREFIX = '__NUXT_SCRIPT_' +const PLACEHOLDER_TOKEN_RE = /__NUXT_SCRIPT_(?:BUNDLE|INTEGRITY)_[a-f0-9]+__/g /** * Dropping an unresolved hash must remove the whole `, integrity: ..., crossorigin: 'anonymous'` @@ -413,8 +414,12 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti // rebuild re-renders cached chunks that still carry the placeholder tokens, and a // build that failed must be able to resolve the same components again. const componentBundles = new Map() - let patches: PlaceholderPatch[] = [] - let resolution: Promise | undefined + // Vite builds the client and ssr environments from one config, so this factory runs + // once and both share the state here. Each environment has its own module graph and + // reaches its own verdict, so resolution is keyed by environment. Chunks read their + // patch set from the promise they await, never from a field another environment can + // replace mid-render. + const resolutions = new Map>() /** * A pending component is unused unless some importer path reaches a module outside @@ -448,7 +453,7 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti * rebuild can add or drop the importer that makes a widget used, so a decision cached * from an earlier build would ship the wrong src. */ - async function resolvePendingBundles(getModuleInfo: (id: string) => { importers?: string[], dynamicImporters?: string[] } | undefined | null): Promise { + async function resolvePendingBundles(getModuleInfo: (id: string) => { importers?: string[], dynamicImporters?: string[] } | undefined | null): Promise { const pendings = [...componentBundles.values()].flat() const resolved: PlaceholderPatch[] = [] // Downloads overlap across pendings: resolveScriptBundle rethrows fatal failures @@ -470,9 +475,30 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti : integrityRemovalPatch(pending.placeholderIntegrity)) } })) - // Publish only once every download settled, so a rejected build never leaves a - // half-built patch set behind for the next emission to apply. - patches = resolved + // Return only once every download settled, so a rejected build never hands a + // half-built patch set to the chunks awaiting it. + return resolved + } + + /** + * A surviving token would ship as a script `src` or an SRI hash and break at runtime, + * where it is far harder to trace than here. Every token this transform emits is + * quoted, so a miss means the emitted shape stopped matching what the patch expects. + */ + function assertNoPlaceholders(code: string, fileName: string): void { + const survivors = [...new Set(code.match(PLACEHOLDER_TOKEN_RE) ?? [])] + if (!survivors.length) + return + const details = survivors.map((token) => { + for (const pendings of componentBundles.values()) { + for (const pending of pendings) { + if (token === pending.placeholderUrl || token === pending.placeholderIntegrity) + return `${token} (${pending.componentId}, ${pending.downloadOptions.src})` + } + } + return token + }) + throw new Error(`[Nuxt Scripts: Bundle Transformer] Chunk ${fileName} still holds an unresolved script placeholder: ${details.join(', ')}. This is a bug in the bundle transformer. Please report it at https://github.com/nuxt/scripts/issues.`) } /** @@ -485,26 +511,37 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti * render chunks before an awaited `renderStart` settles, which shipped unresolved * placeholders whenever a download outlived rendering. */ + function resolveFor(ctx: { environment?: { name?: string }, getModuleInfo: (id: string) => any }, restart: boolean): Promise { + const key = ctx.environment?.name ?? 'default' + let resolution = restart ? undefined : resolutions.get(key) + if (!resolution) { + resolution = resolvePendingBundles(id => ctx.getModuleInfo(id)) + resolutions.set(key, resolution) + } + return resolution + } + const outputHooks: Pick = { async renderStart() { - resolution = resolvePendingBundles(id => this.getModuleInfo(id)) - await resolution + // Restart here so a rebuild reaches its verdict against the graph it just built. + await resolveFor(this as any, true) }, async renderChunk(code, chunk, outputOptions) { - resolution ??= resolvePendingBundles(id => this.getModuleInfo(id)) - await resolution + const patches = await resolveFor(this as any, false) - if (!patches.length || !code.includes(PLACEHOLDER_PREFIX)) + if (!code.includes(PLACEHOLDER_PREFIX)) return const s = applyPlaceholderPatches(code, patches) + const patched = s ? s.toString() : code + assertNoPlaceholders(patched, chunk.fileName) if (!s) return // A replacement rarely matches the length of its token, so every later mapping in // the chunk shifts. Hand the bundler a map of the edit instead of letting it keep // the pre-patch offsets. return { - code: s.toString(), + code: patched, map: outputOptions.sourcemap ? s.generateMap({ hires: 'boundary', source: chunk.fileName }) as SourceMapInput : undefined, } }, diff --git a/test/unit/bundle-deferred-resolution.test.ts b/test/unit/bundle-deferred-resolution.test.ts index 49c5c2349..f48d6d3e1 100644 --- a/test/unit/bundle-deferred-resolution.test.ts +++ b/test/unit/bundle-deferred-resolution.test.ts @@ -218,3 +218,43 @@ describe('deferred component bundle resolution', () => { expect(fetchMock.mock.calls.map(call => call[0])).toEqual(['https://example.com/new.js']) }) }) + +describe('deferred resolution guards', () => { + beforeEach(() => { + fetchMock.mockReset() + }) + + it('fails the build when a placeholder survives patching', async () => { + mockDownload() + const plugin = makePlugin() + const code = await registerComponent(plugin, 'Alpha.vue', 'https://example.com/alpha.js') + // A minifier that splits the literal leaves the token unquoted, so no patch matches. + const split = code.replace(/(["'`])(__NUXT_SCRIPT_BUNDLE_[a-f0-9]+__)\1/, '$1$2') + + const ctx = makeContext([APP_IMPORTER]) + await plugin.renderStart.call(ctx, {}, {}) + + await expect(plugin.renderChunk.call(ctx, split, { fileName: 'entry.js' }, { sourcemap: false })) + .rejects.toThrow(/unresolved script placeholder.*Alpha\.vue.*alpha\.js/s) + }) + + it('reaches a separate verdict per build environment', async () => { + mockDownload() + const plugin = makePlugin() + const code = await registerComponent(plugin, 'Alpha.vue', 'https://example.com/alpha.js') + + // One plugin instance serves both environments. A chunk must read the patch set of + // its own environment, not whichever one resolved most recently. + const client = { environment: { name: 'client' }, getModuleInfo: () => ({ importers: [APP_IMPORTER], dynamicImporters: [] }) } + const ssr = { environment: { name: 'ssr' }, getModuleInfo: () => ({ importers: [], dynamicImporters: [] }) } + + await plugin.renderStart.call(client, {}, {}) + await plugin.renderStart.call(ssr, {}, {}) + + const clientChunk = await plugin.renderChunk.call(client, code, { fileName: 'client.js' }, { sourcemap: false }) + const ssrChunk = await plugin.renderChunk.call(ssr, code, { fileName: 'ssr.js' }, { sourcemap: false }) + + expect(clientChunk.code, 'the used client verdict must survive the ssr resolution').toMatch(/\/_scripts\/assets\/[a-f0-9]{16}\.js/) + expect(ssrChunk.code).toContain('https://example.com/alpha.js') + }) +}) From 26ee72513ee8a6ea180bf2f0724fbce0b03212bc Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 1 Sep 2026 14:35:22 +1000 Subject: [PATCH 10/10] fix(transform): let components opt out of the reachability check A widget used only through `nuxt-client` inside a server component stopped being bundled. Reachability reads the client module graph, and there every auto-registered component is an entry with no importers, identical for a used and an unused widget. Only the ssr graph shows the importer, and it builds second, so the client build cannot reach the right verdict on its own. Two changes make that recoverable: - `scripts.assets.alwaysBundle` bundles a named component regardless of what the graph proves. Pass `true` to skip the check for every component. - When one environment finds a component reachable after another judged it unused, the build warns, names the component and its src, and says which option to set. The earlier environment has already written its chunks, so this is reported rather than repaired. Covered by a fixture whose widget is reachable only from a `.server.vue` component through `nuxt-client`. Without `alwaysBundle` that build emits no bundled asset at all. Claude-Session: https://claude.ai/code/session_018cCJ2TMLa1tyT1ttEu1L5v --- docs/content/docs/3.api/5.nuxt-config.md | 21 ++++++ packages/script/src/module.ts | 15 ++++ packages/script/src/plugins/transform.ts | 50 ++++++++++++- test/e2e/issue-882-island-widget.test.ts | 50 +++++++++++++ test/fixtures/issue-882-island/app.vue | 6 ++ .../components/WidgetIsland.server.vue | 9 +++ test/fixtures/issue-882-island/nuxt.config.ts | 20 +++++ test/fixtures/issue-882-island/package.json | 3 + test/unit/bundle-deferred-resolution.test.ts | 74 ++++++++++++++++++- 9 files changed, 244 insertions(+), 4 deletions(-) create mode 100644 test/e2e/issue-882-island-widget.test.ts create mode 100644 test/fixtures/issue-882-island/app.vue create mode 100644 test/fixtures/issue-882-island/components/WidgetIsland.server.vue create mode 100644 test/fixtures/issue-882-island/nuxt.config.ts create mode 100644 test/fixtures/issue-882-island/package.json diff --git a/docs/content/docs/3.api/5.nuxt-config.md b/docs/content/docs/3.api/5.nuxt-config.md index e38ecde1b..af5fb33f8 100644 --- a/docs/content/docs/3.api/5.nuxt-config.md +++ b/docs/content/docs/3.api/5.nuxt-config.md @@ -234,3 +234,24 @@ Cache duration for bundled scripts in milliseconds. Scripts older than this will Generates a Subresource Integrity (SRI) hash for each bundled script and adds `integrity` with `crossorigin="anonymous"`. Browsers compare the downloaded script with its declared hash before executing it; see MDN's [Subresource Integrity](https://developer.mozilla.org/en-US/docs/Web/Security/Defenses/Subresource_Integrity) guide. + +## `assets.alwaysBundle`{lang="ts"} + +- Type: `boolean | string[]`{lang="ts"} +- Default: `false` + +Bundles a component's script even when the build cannot prove the component is used. + +A script component only bundles its script once the module graph shows a real importer, so an unused widget never downloads its SDK. A component used only through `nuxt-client` inside a server component has no importer in the client build, so its script falls back to loading from the third-party origin. Name the component here to bundle it anyway. + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + scripts: { + assets: { + alwaysBundle: ['ScriptCalendlyInlineWidget'], + }, + }, +}) +``` + +Pass `true` to bundle every script component regardless of usage. If the build detects this case it warns and names the component to add. diff --git a/packages/script/src/module.ts b/packages/script/src/module.ts index c2ce8bc37..3f748c309 100644 --- a/packages/script/src/module.ts +++ b/packages/script/src/module.ts @@ -295,6 +295,20 @@ export interface ModuleOptions { * @default false */ integrity?: boolean | 'sha256' | 'sha384' | 'sha512' + /** + * Bundle a component's script even when the build cannot prove the component is used. + * + * Auto-registered components are only bundled once the module graph shows a real + * importer, so an unused widget never downloads its SDK. A component used only + * through `nuxt-client` inside a server component has no importer in the client + * graph, so it falls back to loading from the third-party origin. Name it here to + * bundle it anyway, or pass `true` to bundle every component's script. + * + * Values are component names, for example `'ScriptCalendlyInlineWidget'`. + * + * @default false + */ + alwaysBundle?: boolean | string[] } /** * Enable standalone devtools mode. @@ -902,6 +916,7 @@ export default defineNuxtModule({ fetchOptions: config.assets?.fetchOptions, cacheMaxAge: config.assets?.cacheMaxAge, integrity: config.assets?.integrity, + alwaysBundle: config.assets?.alwaysBundle, renderedScript, })) diff --git a/packages/script/src/plugins/transform.ts b/packages/script/src/plugins/transform.ts index 8d18e1b09..a08aedf07 100644 --- a/packages/script/src/plugins/transform.ts +++ b/packages/script/src/plugins/transform.ts @@ -71,6 +71,12 @@ export interface AssetBundlerTransformerOptions { * proves that an auto-registered component has a real importer. */ componentDir?: string + /** + * Bundle a component's script even when the module graph cannot prove the component is + * used. `true` skips the reachability check for every component; an array names the + * components it applies to. + */ + alwaysBundle?: boolean | string[] scripts?: Required[] /** * Merged configuration from both scripts.registry and runtimeConfig.public.scripts @@ -366,6 +372,20 @@ async function resolveScriptBundle( } } +/** + * The reachability check reads the client module graph, where a component used only + * through `nuxt-client` inside a server component has no importer. Naming that component + * here bundles it regardless. + */ +function isAlwaysBundled(componentId: string, alwaysBundle: boolean | string[] | undefined): boolean { + if (typeof alwaysBundle === 'boolean') + return alwaysBundle + if (!alwaysBundle?.length) + return false + const name = componentId.slice(componentId.lastIndexOf('/') + 1).replace(VUE_RE, '') + return alwaysBundle.includes(name) +} + function getComponentId(id: string, componentDir?: string): string | undefined { if (!componentDir) return @@ -420,6 +440,11 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti // patch set from the promise they await, never from a field another environment can // replace mid-render. const resolutions = new Map>() + // Which environments judged a component unused. The client graph builds first and + // cannot see a component used only through `nuxt-client`, so a later environment + // finding the same component reachable is what exposes the miss. + const unusedIn = new Map>() + const warnedComponents = new Set() /** * A pending component is unused unless some importer path reaches a module outside @@ -453,18 +478,22 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti * rebuild can add or drop the importer that makes a widget used, so a decision cached * from an earlier build would ship the wrong src. */ - async function resolvePendingBundles(getModuleInfo: (id: string) => { importers?: string[], dynamicImporters?: string[] } | undefined | null): Promise { + async function resolvePendingBundles(environment: string, getModuleInfo: (id: string) => { importers?: string[], dynamicImporters?: string[] } | undefined | null): Promise { const pendings = [...componentBundles.values()].flat() const resolved: PlaceholderPatch[] = [] // Downloads overlap across pendings: resolveScriptBundle rethrows fatal failures // (only falling back when explicitly configured), and Promise.all preserves that. await Promise.all(pendings.map(async (pending) => { - if (!reachesOutsideComponentDir(pending.componentId, getModuleInfo)) { + const used = isAlwaysBundled(pending.componentId, options.alwaysBundle) + || reachesOutsideComponentDir(pending.componentId, getModuleInfo) + if (!used) { + (unusedIn.get(pending.componentId) ?? unusedIn.set(pending.componentId, new Set()).get(pending.componentId)!).add(environment) resolved.push(urlPatch(pending.placeholderUrl, pending.downloadOptions.src)) if (pending.placeholderIntegrity) resolved.push(integrityRemovalPatch(pending.placeholderIntegrity)) return } + warnWhenAnotherEnvironmentMissedIt(pending) const result = await resolveScriptBundle(pending.downloadOptions, renderedScript, options) @@ -480,6 +509,21 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti return resolved } + /** + * One environment finding a component reachable after another judged it unused means + * the earlier build shipped the third-party src. The client build cannot see a + * component used only through `nuxt-client` inside a server component, and it writes + * its chunks before the ssr graph exists, so this is reported rather than repaired. + */ + function warnWhenAnotherEnvironmentMissedIt(pending: PendingComponentBundle): void { + const missed = unusedIn.get(pending.componentId) + if (!missed?.size || warnedComponents.has(pending.componentId)) + return + warnedComponents.add(pending.componentId) + const name = pending.componentId.slice(pending.componentId.lastIndexOf('/') + 1).replace(VUE_RE, '') + logger.warn(`[Nuxt Scripts: Bundle Transformer] ${name} is used, but the ${[...missed].join(' and ')} build could not prove it. Its script was not bundled there and loads from ${pending.downloadOptions.src}. To bundle it, add '${name}' to \`scripts.assets.alwaysBundle\` in your Nuxt config.`) + } + /** * A surviving token would ship as a script `src` or an SRI hash and break at runtime, * where it is far harder to trace than here. Every token this transform emits is @@ -515,7 +559,7 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti const key = ctx.environment?.name ?? 'default' let resolution = restart ? undefined : resolutions.get(key) if (!resolution) { - resolution = resolvePendingBundles(id => ctx.getModuleInfo(id)) + resolution = resolvePendingBundles(key, id => ctx.getModuleInfo(id)) resolutions.set(key, resolution) } return resolution diff --git a/test/e2e/issue-882-island-widget.test.ts b/test/e2e/issue-882-island-widget.test.ts new file mode 100644 index 000000000..801b82254 --- /dev/null +++ b/test/e2e/issue-882-island-widget.test.ts @@ -0,0 +1,50 @@ +import { readdir, readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { createResolver } from '@nuxt/kit' +import { setup, useTestContext } from '@nuxt/test-utils/e2e' +import { describe, expect, it } from 'vitest' + +const { resolve } = createResolver(import.meta.url) + +await setup({ + rootDir: resolve('../fixtures/issue-882-island'), + build: true, + browser: false, +}) + +async function readClientChunks(): Promise { + const ctx = useTestContext() + const nitroOutputDir = ctx.nuxt + ? ctx.nuxt.options.nitro.output.dir + : ctx.options.nuxtConfig?.nitro?.output?.dir + expect(nitroOutputDir, 'expected the test context to expose the nitro output dir').toBeTruthy() + const clientChunkDir = join(nitroOutputDir!, 'public', '_nuxt') + const entries = await readdir(clientChunkDir) + return Promise.all( + entries.filter(name => name.endsWith('.js')).map(name => readFile(join(clientChunkDir, name), 'utf-8')), + ) +} + +describe('widget used only inside a client island', () => { + it('bundles its script when alwaysBundle names the component', async () => { + const chunks = await readClientChunks() + expect(chunks.length, 'expected built client chunks on disk').toBeGreaterThan(0) + + // Reachability reads the client module graph, where a `nuxt-client` island component + // has no importer. Without `alwaysBundle` this build emits no bundled asset at all + // and the widget loads from calendly.com. + const widgetChunk = chunks.find(code => code.includes('initInlineWidget')) + expect(widgetChunk, 'expected a client chunk carrying the inline widget').toBeTruthy() + + // Assert on the component's own call site. The registry composable keeps its default + // `scriptInput.src` pointing at calendly.com, and the component overrides it, so the + // remote host still appears elsewhere in the chunk. + expect(widgetChunk!, 'the island widget must load from the bundled asset').toMatch( + /src:\s*["'`]\/_scripts\/assets\/[a-f0-9]{16}\.js["'`]\s*,\s*integrity\s*:/, + ) + + for (const code of chunks) { + expect(code, 'a client chunk still contains a placeholder token').not.toContain('__NUXT_SCRIPT_') + } + }) +}) diff --git a/test/fixtures/issue-882-island/app.vue b/test/fixtures/issue-882-island/app.vue new file mode 100644 index 000000000..7523b3c94 --- /dev/null +++ b/test/fixtures/issue-882-island/app.vue @@ -0,0 +1,6 @@ + diff --git a/test/fixtures/issue-882-island/components/WidgetIsland.server.vue b/test/fixtures/issue-882-island/components/WidgetIsland.server.vue new file mode 100644 index 000000000..d558a76aa --- /dev/null +++ b/test/fixtures/issue-882-island/components/WidgetIsland.server.vue @@ -0,0 +1,9 @@ + diff --git a/test/fixtures/issue-882-island/nuxt.config.ts b/test/fixtures/issue-882-island/nuxt.config.ts new file mode 100644 index 000000000..f009818a0 --- /dev/null +++ b/test/fixtures/issue-882-island/nuxt.config.ts @@ -0,0 +1,20 @@ +import { defineNuxtConfig } from 'nuxt/config' + +export default defineNuxtConfig({ + modules: ['@nuxt/scripts'], + scripts: { + assets: { + integrity: true, + // The widget is only used through `nuxt-client` inside a server component, so the + // client module graph shows no importer for it. Without this the client build + // treats it as unused and it loads from the third-party origin. + alwaysBundle: ['ScriptCalendlyInlineWidget'], + }, + }, + experimental: { + componentIslands: { + selectiveClient: true, + }, + }, + compatibilityDate: '2024-07-05', +}) diff --git a/test/fixtures/issue-882-island/package.json b/test/fixtures/issue-882-island/package.json new file mode 100644 index 000000000..352055cdf --- /dev/null +++ b/test/fixtures/issue-882-island/package.json @@ -0,0 +1,3 @@ +{ + "private": true +} diff --git a/test/unit/bundle-deferred-resolution.test.ts b/test/unit/bundle-deferred-resolution.test.ts index f48d6d3e1..2ba10a51c 100644 --- a/test/unit/bundle-deferred-resolution.test.ts +++ b/test/unit/bundle-deferred-resolution.test.ts @@ -5,6 +5,7 @@ // - a build that failed leaves its registrations intact for the next attempt import type { AssetBundlerTransformerOptions } from '../../packages/script/src/plugins/transform' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { logger } from '../../packages/script/src/logger' import { NuxtScriptBundleTransformer } from '../../packages/script/src/plugins/transform' const mockBundleStorage: any = { @@ -235,7 +236,8 @@ describe('deferred resolution guards', () => { await plugin.renderStart.call(ctx, {}, {}) await expect(plugin.renderChunk.call(ctx, split, { fileName: 'entry.js' }, { sourcemap: false })) - .rejects.toThrow(/unresolved script placeholder.*Alpha\.vue.*alpha\.js/s) + .rejects + .toThrow(/unresolved script placeholder.*Alpha\.vue.*alpha\.js/s) }) it('reaches a separate verdict per build environment', async () => { @@ -258,3 +260,73 @@ describe('deferred resolution guards', () => { expect(ssrChunk.code).toContain('https://example.com/alpha.js') }) }) + +describe('alwaysBundle escape hatch', () => { + beforeEach(() => { + fetchMock.mockReset() + }) + + it('bundles a named component the module graph cannot prove is used', async () => { + mockDownload() + const plugin = makePlugin({ alwaysBundle: ['Alpha'] }) + const code = await registerComponent(plugin, 'Alpha.vue', 'https://example.com/alpha.js') + + // No importer leaves the components dir, which is what a `nuxt-client` island looks + // like in the client graph. + const result = await render(plugin, code, []) + + expect(result.code).toMatch(/\/_scripts\/assets\/[a-f0-9]{16}\.js/) + expect(result.code).not.toContain('https://example.com/alpha.js') + }) + + it('leaves components it does not name alone', async () => { + mockDownload() + const plugin = makePlugin({ alwaysBundle: ['SomethingElse'] }) + const code = await registerComponent(plugin, 'Alpha.vue', 'https://example.com/alpha.js') + + const result = await render(plugin, code, []) + + expect(fetchMock).not.toHaveBeenCalled() + expect(result.code).toContain('https://example.com/alpha.js') + }) + + it('true bundles every component regardless of reachability', async () => { + mockDownload() + const plugin = makePlugin({ alwaysBundle: true }) + const code = await registerComponent(plugin, 'Alpha.vue', 'https://example.com/alpha.js') + + const result = await render(plugin, code, []) + + expect(result.code).toMatch(/\/_scripts\/assets\/[a-f0-9]{16}\.js/) + }) + + it('warns when one environment proves a component another environment missed', async () => { + mockDownload() + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}) + const plugin = makePlugin() + await registerComponent(plugin, 'Alpha.vue', 'https://example.com/alpha.js') + + const client = { environment: { name: 'client' }, getModuleInfo: () => ({ importers: [], dynamicImporters: [] }) } + const ssr = { environment: { name: 'ssr' }, getModuleInfo: () => ({ importers: [APP_IMPORTER], dynamicImporters: [] }) } + await plugin.renderStart.call(client, {}, {}) + await plugin.renderStart.call(ssr, {}, {}) + + expect(warn).toHaveBeenCalledWith(expect.stringMatching(/Alpha is used, but the client build could not prove it.*alwaysBundle/s)) + warn.mockRestore() + }) + + it('stays quiet when every environment agrees', async () => { + mockDownload() + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}) + const plugin = makePlugin() + await registerComponent(plugin, 'Alpha.vue', 'https://example.com/alpha.js') + + const client = { environment: { name: 'client' }, getModuleInfo: () => ({ importers: [APP_IMPORTER], dynamicImporters: [] }) } + const ssr = { environment: { name: 'ssr' }, getModuleInfo: () => ({ importers: [APP_IMPORTER], dynamicImporters: [] }) } + await plugin.renderStart.call(client, {}, {}) + await plugin.renderStart.call(ssr, {}, {}) + + expect(warn).not.toHaveBeenCalled() + warn.mockRestore() + }) +})