From 4c1dfd9397d07584f7f16fa1e7d994b03ae80416 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Mon, 24 Aug 2026 20:11:14 +1000 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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')) {