From 15122a8a25cadbf6a1719b056699ec58d9b7eafc Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Tue, 26 May 2026 03:16:05 +1000 Subject: [PATCH 01/14] fix(app-router): preload next/dynamic chunks with CSP nonce Rendered next/dynamic boundaries produced no SSR preload links for their client chunks. That diverged from Next.js and broke nonce-based CSP tests that inspect preload tags before hydration. The runtime assumed React.lazy alone was enough for dynamic imports. It never carried the dynamic module IDs from the source call into SSR, and client JS chunk URLs did not use Next.js's static/chunks path shape. Add a focused dynamic metadata transform, resolve boundary files from the client build manifest, emit nonce-bearing preload hints during SSR, and keep client JS assets under _next/static/chunks. Ported regression coverage verifies the nonce and the emitted path shape. --- .../vinext/src/build/client-build-config.ts | 14 +- packages/vinext/src/global.d.ts | 9 + packages/vinext/src/index.ts | 105 ++++-- .../src/plugins/dynamic-preload-metadata.ts | 357 ++++++++++++++++++ packages/vinext/src/server/prod-server.ts | 58 ++- packages/vinext/src/shims/dynamic.ts | 84 ++++- .../vinext/src/shims/script-nonce-context.tsx | 26 +- packages/vinext/src/utils/lazy-chunks.ts | 86 +++++ tests/app-router.test.ts | 38 +- tests/build-optimization.test.ts | 140 ++++++- tests/deploy.test.ts | 71 +++- tests/pages-router.test.ts | 6 +- 12 files changed, 917 insertions(+), 77 deletions(-) create mode 100644 packages/vinext/src/plugins/dynamic-preload-metadata.ts diff --git a/packages/vinext/src/build/client-build-config.ts b/packages/vinext/src/build/client-build-config.ts index bb977fbae..5693d9d47 100644 --- a/packages/vinext/src/build/client-build-config.ts +++ b/packages/vinext/src/build/client-build-config.ts @@ -85,8 +85,20 @@ export function createClientManualChunks(shimsDir: string) { * compression efficiency — small files restart the compression dictionary, * adding ~5-15% wire overhead vs fewer larger chunks. */ -export function createClientOutputConfig(clientManualChunks: (id: string) => string | undefined) { +export function createClientFileNameConfig(assetsDir: string) { + const chunksDir = `${assetsDir}/chunks`; return { + entryFileNames: `${chunksDir}/[name]-[hash].js`, + chunkFileNames: `${chunksDir}/[name]-[hash].js`, + }; +} + +export function createClientOutputConfig( + clientManualChunks: (id: string) => string | undefined, + assetsDir: string, +) { + return { + ...createClientFileNameConfig(assetsDir), manualChunks: clientManualChunks, experimentalMinChunkSize: 10_000, }; diff --git a/packages/vinext/src/global.d.ts b/packages/vinext/src/global.d.ts index f9ada2616..763ed7cf9 100644 --- a/packages/vinext/src/global.d.ts +++ b/packages/vinext/src/global.d.ts @@ -233,6 +233,15 @@ declare global { // oxlint-disable-next-line no-var var __VINEXT_LAZY_CHUNKS__: string[] | undefined; + /** + * Per-module preload files for rendered `next/dynamic()` boundaries. + * Keys are root-relative module IDs injected by vinext's dynamic metadata + * transform. Values are JS/CSS files from Vite's build manifest, with any + * configured base path already applied. + */ + // oxlint-disable-next-line no-var + var __VINEXT_DYNAMIC_PRELOADS__: Record | undefined; + /** * The client entry JS filename (e.g. `"_next/static/entry-abc123.js"`) for Pages * Router builds. diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 3762521dd..41274e13b 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -95,6 +95,7 @@ import { createRscClientReferenceLoadersPlugin } from "./plugins/rsc-client-refe import { createInstrumentationClientTransformPlugin } from "./plugins/instrumentation-client.js"; import { createMiddlewareServerOnlyPlugin } from "./plugins/middleware-server-only.js"; import { createOptimizeImportsPlugin } from "./plugins/optimize-imports.js"; +import { createDynamicPreloadMetadataPlugin } from "./plugins/dynamic-preload-metadata.js"; import { createOgInlineFetchAssetsPlugin, ogAssetsPlugin } from "./plugins/og-assets.js"; import { generateRouteTypes } from "./typegen.js"; import { @@ -112,10 +113,15 @@ import { createLocalFontsPlugin, } from "./plugins/fonts.js"; import { hasWranglerConfig, formatMissingCloudflarePluginError } from "./deploy.js"; -import { computeLazyChunks } from "./utils/lazy-chunks.js"; +import { + computeDynamicImportPreloads, + computeLazyChunks, + dynamicImportPreloadsWithBase, +} from "./utils/lazy-chunks.js"; import { resolvePostcssStringPlugins } from "./plugins/postcss.js"; import { buildSassPreprocessorOptions } from "./plugins/sass.js"; import { + createClientFileNameConfig, createClientManualChunks, createClientOutputConfig, createClientCodeSplittingConfig, @@ -537,11 +543,14 @@ const _reactServerShims = new Map([ ]); const clientManualChunks = createClientManualChunks(_shimsDir); -const clientOutputConfig = createClientOutputConfig(clientManualChunks); -const clientCodeSplittingConfig = createClientCodeSplittingConfig(clientManualChunks); -function getClientOutputConfigForVite(viteMajorVersion: number) { - return viteMajorVersion >= 8 ? { codeSplitting: clientCodeSplittingConfig } : clientOutputConfig; +function getClientOutputConfigForVite(viteMajorVersion: number, assetsDir: string) { + return viteMajorVersion >= 8 + ? { + ...createClientFileNameConfig(assetsDir), + codeSplitting: createClientCodeSplittingConfig(clientManualChunks), + } + : createClientOutputConfig(clientManualChunks, assetsDir); } export type VinextOptions = { @@ -1509,7 +1518,12 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // manualChunks is set per-environment on the client env below // to avoid leaking into RSC/SSR environments. ...(!isSSR && !isMultiEnv - ? { output: getClientOutputConfigForVite(viteMajorVersion) } + ? { + output: getClientOutputConfigForVite( + viteMajorVersion, + resolveAssetsDir(nextConfig.assetPrefix ?? ""), + ), + } : {}), }), }, @@ -1872,17 +1886,18 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { ], }, build: { - // When targeting Cloudflare Workers, enable manifest generation - // so the vinext:cloudflare-build closeBundle hook can read the - // client build manifest, compute lazy chunks (only reachable - // via dynamic imports), and inject __VINEXT_LAZY_CHUNKS__ into - // the worker entry. Without this, all chunks are modulepreloaded - // on every page — defeating code-splitting for React.lazy() and - // next/dynamic boundaries. - ...(hasCloudflarePlugin ? { manifest: true } : {}), + // Production App Router rendering needs Vite's client manifest + // to resolve next/dynamic module IDs to the exact JS/CSS files + // that should be preloaded when a dynamic boundary renders. + // Cloudflare builds also use it to inject lazy chunk metadata + // into the Worker entry. + manifest: true, ...withBuildBundlerOptions(viteMajorVersion, { input: { index: VIRTUAL_APP_BROWSER_ENTRY }, - output: getClientOutputConfigForVite(viteMajorVersion), + output: getClientOutputConfigForVite( + viteMajorVersion, + resolveAssetsDir(nextConfig.assetPrefix ?? ""), + ), treeshake: getClientTreeshakeConfigForVite(viteMajorVersion), }), }, @@ -1903,7 +1918,10 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { ssrManifest: true, ...withBuildBundlerOptions(viteMajorVersion, { input: { index: VIRTUAL_CLIENT_ENTRY }, - output: getClientOutputConfigForVite(viteMajorVersion), + output: getClientOutputConfigForVite( + viteMajorVersion, + resolveAssetsDir(nextConfig.assetPrefix ?? ""), + ), treeshake: getClientTreeshakeConfigForVite(viteMajorVersion), }), }, @@ -1929,7 +1947,10 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { ssrManifest: true, ...withBuildBundlerOptions(viteMajorVersion, { input: { index: VIRTUAL_CLIENT_ENTRY }, - output: getClientOutputConfigForVite(viteMajorVersion), + output: getClientOutputConfigForVite( + viteMajorVersion, + resolveAssetsDir(nextConfig.assetPrefix ?? ""), + ), treeshake: getClientTreeshakeConfigForVite(viteMajorVersion), }), }, @@ -3529,6 +3550,12 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { () => nextConfig, () => root, ), + // next/dynamic preload metadata: + // Mirrors Next.js's react-loadable transform by recording which resolved + // module IDs belong to each dynamic() boundary. The runtime resolves those + // IDs through Vite's build manifest so it can emit boundary-scoped preload + // hints with the request CSP nonce. + createDynamicPreloadMetadataPlugin(), // "use cache" directive transform: // Detects "use cache" at file-level or function-level and wraps the // exports/functions with registerCachedFunction() from vinext/cache-runtime. @@ -4028,14 +4055,16 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { }; })(), // Cloudflare Workers production build integration: - // After all environments are built, compute lazy chunks from the client - // build manifest and inject globals into the worker entry. + // After all environments are built, compute dynamic chunk metadata from + // the client build manifest and inject globals into the worker entry. // // Pages Router: injects __VINEXT_CLIENT_ENTRY__, __VINEXT_SSR_MANIFEST__, - // and __VINEXT_LAZY_CHUNKS__ into the worker entry (found via wrangler.json). + // __VINEXT_LAZY_CHUNKS__, and __VINEXT_DYNAMIC_PRELOADS__ into the worker + // entry (found via wrangler.json). // App Router: the RSC plugin handles __VINEXT_CLIENT_ENTRY__ via - // loadBootstrapScriptContent(), but we still inject __VINEXT_LAZY_CHUNKS__ - // and __VINEXT_SSR_MANIFEST__ into the worker entry at dist/server/index.js. + // loadBootstrapScriptContent(), but we still inject dynamic preload + // metadata and __VINEXT_SSR_MANIFEST__ into the worker entry at + // dist/server/index.js. // Both: generates _headers file for immutable asset caching. { name: "vinext:cloudflare-build", @@ -4059,10 +4088,12 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { const clientBase = envConfig.base ?? "/"; // Read build manifest and compute lazy chunks (only reachable via - // dynamic imports). This runs for BOTH App Router and Pages Router. + // dynamic imports), plus per-next/dynamic preload files. This runs + // for BOTH App Router and Pages Router. // clientEntryFile is only used by the Pages Router path below — // App Router gets its client entry via the RSC plugin instead. let lazyChunksData: string[] | null = null; + let dynamicPreloadsData: Record | null = null; let clientEntryFile: string | null = null; const buildManifestPath = path.join(clientDir, ".vite", "manifest.json"); if (fs.existsSync(buildManifestPath)) { @@ -4077,6 +4108,13 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } const lazy = manifestFilesWithBase(computeLazyChunks(buildManifest), clientBase); if (lazy.length > 0) lazyChunksData = lazy; + const dynamicPreloads = dynamicImportPreloadsWithBase( + computeDynamicImportPreloads(buildManifest), + (file) => manifestFileWithBase(file, clientBase), + ); + if (Object.keys(dynamicPreloads).length > 0) { + dynamicPreloadsData = dynamicPreloads; + } } catch { /* ignore parse errors */ } @@ -4096,10 +4134,13 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { if (hasAppDir) { // App Router: the RSC plugin handles __VINEXT_CLIENT_ENTRY__ // via loadBootstrapScriptContent(), but we still need to inject - // __VINEXT_LAZY_CHUNKS__ and __VINEXT_SSR_MANIFEST__ into the - // worker entry at dist/server/index.js. + // __VINEXT_LAZY_CHUNKS__, __VINEXT_DYNAMIC_PRELOADS__, and + // __VINEXT_SSR_MANIFEST__ into the worker entry at dist/server/index.js. const workerEntry = path.resolve(distDir, "server", "index.js"); - if (fs.existsSync(workerEntry) && (lazyChunksData || ssrManifestData)) { + if ( + fs.existsSync(workerEntry) && + (lazyChunksData || dynamicPreloadsData || ssrManifestData) + ) { let code = fs.readFileSync(workerEntry, "utf-8"); const globals: string[] = []; if (ssrManifestData) { @@ -4112,6 +4153,11 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { `globalThis.__VINEXT_LAZY_CHUNKS__ = ${JSON.stringify(lazyChunksData)};`, ); } + if (dynamicPreloadsData) { + globals.push( + `globalThis.__VINEXT_DYNAMIC_PRELOADS__ = ${JSON.stringify(dynamicPreloadsData)};`, + ); + } code = globals.join("\n") + "\n" + code; fs.writeFileSync(workerEntry, code); } @@ -4161,7 +4207,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } // Prepend globals to worker entry - if (clientEntryFile || ssrManifestData || lazyChunksData) { + if (clientEntryFile || ssrManifestData || lazyChunksData || dynamicPreloadsData) { let code = fs.readFileSync(workerEntry, "utf-8"); const globals: string[] = []; if (clientEntryFile) { @@ -4179,6 +4225,11 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { `globalThis.__VINEXT_LAZY_CHUNKS__ = ${JSON.stringify(lazyChunksData)};`, ); } + if (dynamicPreloadsData) { + globals.push( + `globalThis.__VINEXT_DYNAMIC_PRELOADS__ = ${JSON.stringify(dynamicPreloadsData)};`, + ); + } code = globals.join("\n") + "\n" + code; fs.writeFileSync(workerEntry, code); } diff --git a/packages/vinext/src/plugins/dynamic-preload-metadata.ts b/packages/vinext/src/plugins/dynamic-preload-metadata.ts new file mode 100644 index 000000000..03c1dc10d --- /dev/null +++ b/packages/vinext/src/plugins/dynamic-preload-metadata.ts @@ -0,0 +1,357 @@ +import type { Plugin } from "vite"; +import { parseAst } from "vite"; +import MagicString from "magic-string"; +import path from "node:path"; + +type AstRecord = { + [key: string]: unknown; +}; + +type TransformResult = { + code: string; + map: ReturnType; +}; + +type ResolveDynamicImport = (specifier: string, importer: string) => Promise; + +function isRecord(value: unknown): value is AstRecord { + return !!value && typeof value === "object"; +} + +function getString(node: AstRecord, key: string): string | null { + const value = node[key]; + return typeof value === "string" ? value : null; +} + +function getNumber(node: AstRecord, key: string): number | null { + const value = node[key]; + return typeof value === "number" ? value : null; +} + +function getArray(node: AstRecord, key: string): unknown[] { + const value = node[key]; + return Array.isArray(value) ? value : []; +} + +function nodeName(node: unknown): string | null { + if (!isRecord(node)) return null; + const name = node.name; + if (typeof name === "string") return name; + const value = node.value; + return typeof value === "string" ? value : null; +} + +function nodeStringValue(node: unknown): string | null { + if (!isRecord(node)) return null; + const value = node.value; + return typeof value === "string" ? value : null; +} + +function walkAst(value: unknown, visitor: (node: AstRecord) => void): void { + if (!isRecord(value)) return; + visitor(value); + + for (const [key, child] of Object.entries(value)) { + if (key === "parent") continue; + if (Array.isArray(child)) { + for (const item of child) { + walkAst(item, visitor); + } + } else if (isRecord(child)) { + walkAst(child, visitor); + } + } +} + +function importSource(node: AstRecord): string | null { + const source = node.source; + if (!isRecord(source)) return null; + return nodeStringValue(source); +} + +function isNextDynamicSource(source: string | null): boolean { + return source === "next/dynamic" || source === "next/dynamic.js"; +} + +function collectDynamicImportLocals(ast: unknown): Set { + const locals = new Set(); + if (!isRecord(ast)) return locals; + + for (const node of getArray(ast, "body")) { + if (!isRecord(node)) continue; + if (getString(node, "type") !== "ImportDeclaration") continue; + if (!isNextDynamicSource(importSource(node))) continue; + + for (const specifier of getArray(node, "specifiers")) { + if (!isRecord(specifier)) continue; + if (getString(specifier, "type") !== "ImportDefaultSpecifier") continue; + const local = nodeName(specifier.local); + if (local) locals.add(local); + } + } + + return locals; +} + +function isIdentifierNamed(node: unknown, names: Set): boolean { + if (!isRecord(node)) return false; + return getString(node, "type") === "Identifier" && names.has(getString(node, "name") ?? ""); +} + +function isDynamicCall(node: AstRecord, dynamicLocals: Set): boolean { + if (getString(node, "type") !== "CallExpression") return false; + return isIdentifierNamed(node.callee, dynamicLocals); +} + +function collectImportSpecifiers(node: unknown): string[] { + const specifiers: string[] = []; + const seen = new Set(); + + walkAst(node, (item) => { + if (getString(item, "type") === "ImportExpression") { + const specifier = nodeStringValue(item.source); + if (specifier && !seen.has(specifier)) { + seen.add(specifier); + specifiers.push(specifier); + } + return; + } + + if (getString(item, "type") !== "CallExpression") return; + const callee = item.callee; + if (!isRecord(callee) || getString(callee, "type") !== "Import") return; + const firstArg = getArray(item, "arguments")[0]; + const specifier = nodeStringValue(firstArg); + if (specifier && !seen.has(specifier)) { + seen.add(specifier); + specifiers.push(specifier); + } + }); + + return specifiers; +} + +function propertyKeyName(property: unknown): string | null { + if (!isRecord(property)) return null; + return nodeName(property.key); +} + +function objectProperties(node: unknown): AstRecord[] { + if (!isRecord(node) || getString(node, "type") !== "ObjectExpression") return []; + return getArray(node, "properties").filter(isRecord); +} + +function hasObjectProperty(node: unknown, name: string): boolean { + return objectProperties(node).some((property) => propertyKeyName(property) === name); +} + +function findLastEndedProperty(node: AstRecord): AstRecord | null { + const properties = objectProperties(node); + for (let index = properties.length - 1; index >= 0; index -= 1) { + if (getNumber(properties[index], "end") !== null) { + return properties[index]; + } + } + return null; +} + +function appendObjectProperty( + output: MagicString, + objectNode: AstRecord, + property: string, +): boolean { + const start = getNumber(objectNode, "start"); + const end = getNumber(objectNode, "end"); + if (start === null || end === null) return false; + + const lastProperty = findLastEndedProperty(objectNode); + if (!lastProperty) { + output.appendLeft(start + 1, property); + return true; + } + + const propertyEnd = getNumber(lastProperty, "end"); + if (propertyEnd === null) return false; + output.appendLeft(propertyEnd, `, ${property}`); + return true; +} + +function insertSecondOptionsArgument( + output: MagicString, + code: string, + callNode: AstRecord, + firstArg: AstRecord, + optionsLiteral: string, +): boolean { + const callEnd = getNumber(callNode, "end"); + const firstArgEnd = getNumber(firstArg, "end"); + if (callEnd === null || firstArgEnd === null) return false; + + const closeParen = callEnd - 1; + const betweenFirstArgAndClose = code.slice(firstArgEnd, closeParen); + if (betweenFirstArgAndClose.includes(",")) { + output.overwrite(firstArgEnd, closeParen, `, ${optionsLiteral}`); + } else { + output.appendLeft(closeParen, `, ${optionsLiteral}`); + } + return true; +} + +function cleanResolvedId(id: string): string { + let start = 0; + while (start < id.length && id.charCodeAt(start) === 0) { + start += 1; + } + + return id + .slice(start) + .replace(/^\/@fs\//, "/") + .split("?")[0] + .replace(/\\/g, "/"); +} + +function toManifestModuleId(root: string, resolvedId: string): string | null { + const cleaned = cleanResolvedId(resolvedId); + if (!path.isAbsolute(cleaned)) return cleaned.replace(/^\/+/, ""); + + const relative = path.relative(root, cleaned); + if (relative.startsWith("..") || path.isAbsolute(relative)) return null; + return relative.split(path.sep).join("/"); +} + +async function resolveManifestModuleIds( + specifiers: readonly string[], + importer: string, + root: string, + resolveDynamicImport: ResolveDynamicImport, +): Promise { + const resolvedIds: string[] = []; + const seen = new Set(); + + for (const specifier of specifiers) { + const resolved = await resolveDynamicImport(specifier, importer); + const moduleId = resolved ? toManifestModuleId(root, resolved) : null; + if (!moduleId || seen.has(moduleId)) continue; + seen.add(moduleId); + resolvedIds.push(moduleId); + } + + return resolvedIds; +} + +function shouldSkipCall(firstArg: unknown, secondArg: unknown): boolean { + if (hasObjectProperty(firstArg, "loadableGenerated")) return true; + return hasObjectProperty(secondArg, "loadableGenerated"); +} + +function applyLoadableGenerated( + output: MagicString, + code: string, + callNode: AstRecord, + moduleIds: readonly string[], +): boolean { + const args = getArray(callNode, "arguments"); + const firstArg = args[0]; + const secondArg = args[1]; + if (!isRecord(firstArg)) return false; + if (shouldSkipCall(firstArg, secondArg)) return false; + + const property = `loadableGenerated: { modules: ${JSON.stringify(moduleIds)} }`; + const firstArgIsObject = getString(firstArg, "type") === "ObjectExpression"; + if (firstArgIsObject) { + return appendObjectProperty(output, firstArg, property); + } + + if (secondArg === undefined) { + return insertSecondOptionsArgument(output, code, callNode, firstArg, `{ ${property} }`); + } + + if (isRecord(secondArg) && getString(secondArg, "type") === "ObjectExpression") { + return appendObjectProperty(output, secondArg, property); + } + + return false; +} + +export async function transformNextDynamicPreloadMetadata( + code: string, + id: string, + root: string, + resolveDynamicImport: ResolveDynamicImport, +): Promise { + if (!code.includes("next/dynamic") || !code.includes("import(")) return null; + + let ast: unknown; + try { + ast = parseAst(code); + } catch { + return null; + } + + const dynamicLocals = collectDynamicImportLocals(ast); + if (dynamicLocals.size === 0) return null; + + const output = new MagicString(code); + let changed = false; + const pending: Promise[] = []; + + walkAst(ast, (node) => { + if (!isDynamicCall(node, dynamicLocals)) return; + const args = getArray(node, "arguments"); + const specifiers = collectImportSpecifiers(args[0]); + if (specifiers.length === 0) return; + + pending.push( + resolveManifestModuleIds(specifiers, id, root, resolveDynamicImport).then((moduleIds) => { + if (moduleIds.length === 0) return; + if (applyLoadableGenerated(output, code, node, moduleIds)) { + changed = true; + } + }), + ); + }); + + await Promise.all(pending); + + if (!changed) return null; + return { + code: output.toString(), + map: output.generateMap({ hires: "boundary" }), + }; +} + +export function createDynamicPreloadMetadataPlugin(): Plugin { + let root = process.cwd(); + + return { + name: "vinext:dynamic-preload-metadata", + configResolved(config) { + root = config.root; + }, + transform: { + filter: { + id: { + include: /\.(tsx?|jsx?|mjs)$/, + exclude: /node_modules/, + }, + code: "next/dynamic", + }, + async handler(code, id) { + if (id.includes("node_modules") || id.startsWith("\0")) return null; + if (!/\.(tsx?|jsx?|mjs)$/.test(id)) return null; + + const result = await transformNextDynamicPreloadMetadata( + code, + id, + root, + async (specifier) => { + const resolved = await this.resolve(specifier, id, { skipSelf: true }); + return resolved?.id ?? null; + }, + ); + if (!result) return null; + return result; + }, + }, + }; +} diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index 3d77a8dba..8cb6d7bcf 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -65,7 +65,11 @@ import { assetPrefixPathname, isAbsoluteAssetPrefix, } from "../utils/asset-prefix.js"; -import { computeLazyChunks } from "../utils/lazy-chunks.js"; +import { + computeDynamicImportPreloads, + computeLazyChunks, + dynamicImportPreloadsWithBase, +} from "../utils/lazy-chunks.js"; import { manifestFileWithBase } from "../utils/manifest-paths.js"; import { normalizePathnameForRouteMatchStrict } from "../routing/utils.js"; import type { ExecutionContextLike } from "vinext/shims/request-context"; @@ -257,6 +261,34 @@ function stripHeaders( } } +function installClientBuildManifestGlobals(clientDir: string, assetBase: string): void { + globalThis.__VINEXT_LAZY_CHUNKS__ = undefined; + globalThis.__VINEXT_DYNAMIC_PRELOADS__ = undefined; + + const buildManifestPath = path.join(clientDir, ".vite", "manifest.json"); + if (!fs.existsSync(buildManifestPath)) return; + + try { + const buildManifest = JSON.parse(fs.readFileSync(buildManifestPath, "utf-8")); + const lazyChunks = computeLazyChunks(buildManifest).map((file: string) => + manifestFileWithBase(file, assetBase), + ); + if (lazyChunks.length > 0) { + globalThis.__VINEXT_LAZY_CHUNKS__ = lazyChunks; + } + + const dynamicPreloads = dynamicImportPreloadsWithBase( + computeDynamicImportPreloads(buildManifest), + (file) => manifestFileWithBase(file, assetBase), + ); + if (Object.keys(dynamicPreloads).length > 0) { + globalThis.__VINEXT_DYNAMIC_PRELOADS__ = dynamicPreloads; + } + } catch { + /* ignore parse errors */ + } +} + function isNoBodyResponseStatus(status: number): boolean { return NO_BODY_RESPONSE_STATUSES.has(status); } @@ -1080,11 +1112,15 @@ async function startAppRouterServer(options: AppRouterServerOptions) { // continue to work with the historical asset layout. const appRouterAssetPrefix: string = typeof rscModule.__assetPrefix === "string" ? rscModule.__assetPrefix : ""; + const appRouterBasePath: string = + typeof rscModule.__basePath === "string" ? rscModule.__basePath : ""; // Path portion of the assetPrefix to match incoming asset requests against // (empty when the prefix is an absolute URL with no path component, or when // no prefix is configured). The URL prefix the prod-server needs to strip // before locating files on disk includes this path plus `_next/static/`. const appAssetPathPrefix = assetPrefixPathname(appRouterAssetPrefix); + const appAssetBase = appRouterBasePath ? `${appRouterBasePath}/` : "/"; + installClientBuildManifestGlobals(clientDir, appAssetBase); // Seed the memory cache with pre-rendered routes so the first request to // any pre-rendered page is a cache HIT instead of a full re-render. @@ -1407,23 +1443,9 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { ssrManifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8")); } - // Load the build manifest to compute lazy chunks — chunks only reachable via - // dynamic imports (React.lazy, next/dynamic). These should not be - // modulepreloaded since they are fetched on demand. - const buildManifestPath = path.join(clientDir, ".vite", "manifest.json"); - if (fs.existsSync(buildManifestPath)) { - try { - const buildManifest = JSON.parse(fs.readFileSync(buildManifestPath, "utf-8")); - const lazyChunks = computeLazyChunks(buildManifest).map((file: string) => - manifestFileWithBase(file, assetBase), - ); - if (lazyChunks.length > 0) { - globalThis.__VINEXT_LAZY_CHUNKS__ = lazyChunks; - } - } catch { - /* ignore parse errors */ - } - } + // Load the build manifest to compute lazy chunks and rendered next/dynamic + // preload files. These globals are read by shims during SSR. + installClientBuildManifestGlobals(clientDir, assetBase); // Build the static file metadata cache at startup (same as App Router). const staticCache = await StaticFileCache.create(clientDir); diff --git a/packages/vinext/src/shims/dynamic.ts b/packages/vinext/src/shims/dynamic.ts index ff8b174c4..42ddb039a 100644 --- a/packages/vinext/src/shims/dynamic.ts +++ b/packages/vinext/src/shims/dynamic.ts @@ -20,6 +20,8 @@ * - dynamic(() => import('./Component'), { ssr: false }) */ import React, { type ComponentType } from "react"; +import * as ReactDOM from "react-dom"; +import { useScriptNonce } from "./script-nonce-context.js"; type DynamicLoadingProps = { error?: Error | null; @@ -36,6 +38,10 @@ type LoaderFn

= () => LoaderComponent

; type DynamicOptions

= { loading?: ComponentType; loader?: Loader

; + loadableGenerated?: { + modules?: readonly string[]; + }; + modules?: readonly string[]; ssr?: boolean; }; @@ -96,6 +102,74 @@ function createLazyComponent

(loader: LoaderFn

) { }); } +function dynamicPreloadHref(file: string): string { + if ( + file.startsWith("/") || + file.startsWith("http://") || + file.startsWith("https://") || + file.startsWith("//") + ) { + return file; + } + return `/${file}`; +} + +function resolveDynamicPreloadFiles(moduleIds: readonly string[] | undefined): string[] { + if (!moduleIds || moduleIds.length === 0) return []; + + const preloadMap = globalThis.__VINEXT_DYNAMIC_PRELOADS__; + if (!preloadMap) return []; + + const files: string[] = []; + const seen = new Set(); + for (const moduleId of moduleIds) { + for (const file of preloadMap[moduleId] ?? []) { + if (seen.has(file)) continue; + seen.add(file); + files.push(file); + } + } + + return files; +} + +function DynamicPreloadChunks(props: { moduleIds?: readonly string[] }) { + const nonce = useScriptNonce(); + const files = resolveDynamicPreloadFiles(props.moduleIds); + if (files.length === 0) return null; + + const stylesheets: React.ReactNode[] = []; + for (const file of files) { + const href = dynamicPreloadHref(file); + if (href.endsWith(".css")) { + stylesheets.push( + React.createElement("link", { + key: href, + rel: "stylesheet", + as: "style", + href, + nonce, + precedence: "dynamic", + }), + ); + continue; + } + + if (href.endsWith(".js") && typeof ReactDOM.preload === "function") { + const preloadOptions: ReactDOM.PreloadOptions = { + as: "script", + fetchPriority: "low", + }; + if (nonce !== undefined) { + preloadOptions.nonce = nonce; + } + ReactDOM.preload(href, preloadOptions); + } + } + + return stylesheets.length > 0 ? React.createElement(React.Fragment, null, ...stylesheets) : null; +} + function useRetryableLazyComponent

( loader: LoaderFn

, initialLazyComponent: ReturnType>, @@ -194,10 +268,13 @@ function dynamic

( ): ComponentType

{ const { loader: dynamicLoader, + loadableGenerated, loading: LoadingComponent, + modules, ssr = true, } = normalizeDynamicOptions(dynamicInput, options); const loader = dynamicLoader ? normalizeLoader(dynamicLoader) : () => Promise.resolve(() => null); + const preloadModuleIds = loadableGenerated?.modules ?? modules; // ssr: false — render nothing on the server, lazy-load on client if (!ssr) { @@ -296,7 +373,12 @@ function dynamic

( ); } } - return React.createElement(React.Suspense, { fallback }, content); + return React.createElement( + React.Fragment, + null, + React.createElement(DynamicPreloadChunks, { moduleIds: preloadModuleIds }), + React.createElement(React.Suspense, { fallback }, content), + ); }; ServerDynamic.displayName = "DynamicServer"; diff --git a/packages/vinext/src/shims/script-nonce-context.tsx b/packages/vinext/src/shims/script-nonce-context.tsx index 1e42b4dc9..14341f141 100644 --- a/packages/vinext/src/shims/script-nonce-context.tsx +++ b/packages/vinext/src/shims/script-nonce-context.tsx @@ -1,23 +1,43 @@ import React from "react"; -export const ScriptNonceContext = React.createContext(undefined); +export const ScriptNonceContext = + typeof React.createContext === "function" + ? React.createContext(undefined) + : null; export function ScriptNonceProvider( props: React.PropsWithChildren<{ nonce?: string; }>, ): React.ReactElement { + if (!ScriptNonceContext) { + return React.createElement(React.Fragment, null, props.children); + } return React.createElement(ScriptNonceContext.Provider, { value: props.nonce }, props.children); } export function withScriptNonce(element: React.ReactElement, nonce?: string): React.ReactElement { - if (!nonce) { + if (!nonce || !ScriptNonceContext) { return element; } return React.createElement(ScriptNonceProvider, { nonce }, element); } +function createScriptNonceHook(context: typeof ScriptNonceContext): () => string | undefined { + if (!context || typeof React.useContext !== "function") { + return function useScriptNonceFromContext(): string | undefined { + return undefined; + }; + } + + return function useScriptNonceFromContext(): string | undefined { + return React.useContext(context); + }; +} + +const useScriptNonceFromContext = createScriptNonceHook(ScriptNonceContext); + export function useScriptNonce(): string | undefined { - return React.useContext(ScriptNonceContext); + return useScriptNonceFromContext(); } diff --git a/packages/vinext/src/utils/lazy-chunks.ts b/packages/vinext/src/utils/lazy-chunks.ts index 175cc8a49..d8c576455 100644 --- a/packages/vinext/src/utils/lazy-chunks.ts +++ b/packages/vinext/src/utils/lazy-chunks.ts @@ -85,3 +85,89 @@ export function computeLazyChunks(buildManifest: Record, file: string | undefined): void { + if (!file || seen.has(file)) return; + seen.add(file); + files.push(file); +} + +function collectStaticChunkFiles( + buildManifest: Record, + key: string, + files: string[], + seenFiles: Set, + visitedChunks: Set, +): void { + if (visitedChunks.has(key)) return; + visitedChunks.add(key); + + const chunk = buildManifest[key]; + if (!chunk) return; + + if (chunk.file.endsWith(".js")) { + addFile(files, seenFiles, chunk.file); + } + for (const cssFile of chunk.css ?? []) { + addFile(files, seenFiles, cssFile); + } + + for (const importedKey of chunk.imports ?? []) { + collectStaticChunkFiles(buildManifest, importedKey, files, seenFiles, visitedChunks); + } +} + +/** + * Compute the production preload files for each module referenced by a + * `next/dynamic()` boundary. + * + * Next.js records module IDs during compilation, then resolves those IDs + * against its react-loadable manifest at render time. Vinext's equivalent + * source of truth is Vite's build manifest: each chunk lists the modules it + * reaches through `dynamicImports`, and each dynamic entry lists the JS/CSS + * files required to evaluate it. + * + * @returns A map keyed by root-relative module ID, with JS/CSS files that + * should be preloaded when that dynamic boundary is rendered. + */ +export function computeDynamicImportPreloads( + buildManifest: Record, +): Record { + const preloads: Record = {}; + + for (const chunk of Object.values(buildManifest)) { + for (const dynamicKey of chunk.dynamicImports ?? []) { + const files: string[] = []; + const seenFiles = new Set(); + collectStaticChunkFiles(buildManifest, dynamicKey, files, seenFiles, new Set()); + if (files.length === 0) continue; + + const normalizedKey = normalizeManifestKey(dynamicKey); + const existing = preloads[normalizedKey] ?? []; + const merged = new Set(existing); + for (const file of files) { + if (merged.has(file)) continue; + merged.add(file); + existing.push(file); + } + preloads[normalizedKey] = existing; + } + } + + return preloads; +} + +export function dynamicImportPreloadsWithBase( + preloads: Record, + applyBase: (file: string) => string, +): Record { + const withBase: Record = {}; + for (const [key, files] of Object.entries(preloads)) { + withBase[key] = files.map(applyBase); + } + return withBase; +} diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index eb4bb4c5c..003a64cbb 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -2224,9 +2224,9 @@ describe("App Router Production build", () => { // Client bundle should exist expect(fs.existsSync(path.join(outDir, "client"))).toBe(true); - // Client should have hashed JS assets under Next.js's canonical - // `_next/static/` directory (matches `resolveAssetsDir("")`). - const clientAssets = fs.readdirSync(path.join(outDir, "client", "_next", "static")); + // Client JS should land under Next.js's canonical `_next/static/chunks/` + // directory. + const clientAssets = fs.readdirSync(path.join(outDir, "client", "_next", "static", "chunks")); expect(clientAssets.some((f: string) => f.endsWith(".js"))).toBe(true); // RSC bundle should contain route handling code @@ -2261,7 +2261,9 @@ describe("App Router Production build", () => { expect(homeHtml).toContain(" // tag (via React's bootstrapModules option) referencing hashed assets. - expect(homeHtml).toMatch(/]+type="module"[^>]+src="\/_next\/static\/[^"]+\.js"/); + expect(homeHtml).toMatch( + /]+type="module"[^>]+src="\/_next\/static\/chunks\/[^"]+\.js"/, + ); // Dynamic route works const blogRes = await fetch(`${previewUrl}/blog/test-post`); @@ -2361,6 +2363,27 @@ describe("App Router Production server (startProdServer)", () => { expect(secondHtml).not.toContain('nonce="first"'); }); + it("preloads rendered next/dynamic chunks with the CSP nonce", async () => { + // Ported from Next.js: test/e2e/app-dir/next-dynamic-csp-nonce/next-dynamic-csp-nonce.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/next-dynamic-csp-nonce/next-dynamic-csp-nonce.test.ts + const res = await fetch(`${baseUrl}/nextjs-compat/dynamic?csp-nonce=1`); + expect(res.status).toBe(200); + expect(res.headers.get("content-security-policy")).toBe( + "script-src 'nonce-vinext-test-nonce' 'strict-dynamic';", + ); + + const html = await res.text(); + const dynamicScriptPreloads = + html.match( + /]*\brel="preload")(?=[^>]*\bas="script")(?=[^>]*\bhref="[^"]*\/_next\/static\/chunks\/[^"]*\.js")[^>]*>/g, + ) ?? []; + + expect(dynamicScriptPreloads.length).toBeGreaterThan(0); + for (const tag of dynamicScriptPreloads) { + expect(tag).toContain('nonce="vinext-test-nonce"'); + } + }); + it("does not collapse encoded slashes onto nested routes in production", async () => { const encodedRes = await fetch(`${baseUrl}/headers%2Foverride-from-middleware`); expect(encodedRes.status).toBe(404); @@ -2441,14 +2464,13 @@ describe("App Router Production server (startProdServer)", () => { }); it("serves static assets with cache headers", async () => { - // Find an actual hashed asset from the build (on disk under - // `_next/static/`, matching `resolveAssetsDir("")`). - const assetsDir = path.join(outDir, "client", "_next", "static"); + // Find an actual hashed JS asset from the build. + const assetsDir = path.join(outDir, "client", "_next", "static", "chunks"); const assets = fs.readdirSync(assetsDir); const jsFile = assets.find((f: string) => f.endsWith(".js")); expect(jsFile).toBeDefined(); - const res = await fetch(`${baseUrl}/_next/static/${jsFile}`); + const res = await fetch(`${baseUrl}/_next/static/chunks/${jsFile}`); expect(res.status).toBe(200); expect(res.headers.get("content-type")).toContain("javascript"); expect(res.headers.get("cache-control")).toContain("immutable"); diff --git a/tests/build-optimization.test.ts b/tests/build-optimization.test.ts index fe6fc3bc4..9ce99aa89 100644 --- a/tests/build-optimization.test.ts +++ b/tests/build-optimization.test.ts @@ -17,7 +17,11 @@ import { clientTreeshakeConfig, getClientTreeshakeConfigForVite, } from "../packages/vinext/src/build/client-build-config.js"; -import { computeLazyChunks } from "../packages/vinext/src/utils/lazy-chunks.js"; +import { + computeDynamicImportPreloads, + computeLazyChunks, +} from "../packages/vinext/src/utils/lazy-chunks.js"; +import { transformNextDynamicPreloadMetadata as _transformNextDynamicPreloadMetadata } from "../packages/vinext/src/plugins/dynamic-preload-metadata.js"; import { asyncHooksStubPlugin as _asyncHooksStubPlugin } from "../packages/vinext/src/plugins/async-hooks-stub.js"; // Create a clientManualChunks instance with a test shims directory. @@ -838,6 +842,8 @@ describe("treeshake config integration", () => { // output config should include the min chunk size setting. const output = getBuildBundlerOptions(result).output; expect(output).toBeDefined(); + expect(output.entryFileNames).toBe("_next/static/chunks/[name]-[hash].js"); + expect(output.chunkFileNames).toBe("_next/static/chunks/[name]-[hash].js"); if (output.codeSplitting) { expect(output.codeSplitting.minSize).toBe(10_000); } else { @@ -848,12 +854,11 @@ describe("treeshake config integration", () => { } }, 15000); - it("App Router client env gets manifest: true when Cloudflare plugin is present", async () => { - // When deploying to Cloudflare Workers, the client environment must produce - // a build manifest (manifest.json) so the vinext:cloudflare-build plugin can - // read dynamicImports and compute lazy chunks. Without this, all chunks get - // modulepreloaded on every page, defeating code-splitting for React.lazy() - // and next/dynamic boundaries. + it("App Router client env gets manifest: true for dynamic preload metadata", async () => { + // App Router production rendering uses Vite's client manifest to map + // next/dynamic module IDs to the chunk files that need rendered preload + // hints. Cloudflare builds also read it during closeBundle to inject those + // globals into the Worker entry. const vinext = (await import("../packages/vinext/src/index.js")).default; const plugins = vinext(); @@ -896,11 +901,13 @@ describe("treeshake config integration", () => { }); // Client environment should have manifest: true for lazy chunk detection + // and rendered next/dynamic preload metadata. expect(result.environments).toBeDefined(); expect(result.environments.client).toBeDefined(); expect(result.environments.client.build.manifest).toBe(true); - // Without Cloudflare plugin, manifest should NOT be set (standard App Router) + // Node production App Router needs the same manifest at startProdServer() + // time, so this is no longer Cloudflare-only. const resultNoCf = await (mainPlugin as any).config( { root: tmpDir, @@ -910,7 +917,7 @@ describe("treeshake config integration", () => { { command: "build" }, ); - expect(resultNoCf.environments.client.build.manifest).toBeUndefined(); + expect(resultNoCf.environments.client.build.manifest).toBe(true); } finally { await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); } @@ -1187,6 +1194,121 @@ describe("computeLazyChunks", () => { }); }); +describe("computeDynamicImportPreloads", () => { + it("maps each dynamic import to its own JS and static dependency files", () => { + const manifest = { + "virtual:vinext-app-browser-entry": { + file: "_next/static/app-entry.js", + isEntry: true, + imports: ["node_modules/react/index.js"], + dynamicImports: ["app/dynamic/widget.tsx"], + }, + "node_modules/react/index.js": { + file: "_next/static/framework.js", + }, + "app/dynamic/widget.tsx": { + file: "_next/static/widget.js", + isDynamicEntry: true, + imports: ["app/dynamic/widget-helper.ts"], + css: ["_next/static/widget.css"], + }, + "app/dynamic/widget-helper.ts": { + file: "_next/static/widget-helper.js", + }, + "app/dynamic/unrelated.tsx": { + file: "_next/static/unrelated.js", + isDynamicEntry: true, + }, + }; + + expect(computeDynamicImportPreloads(manifest)).toEqual({ + "app/dynamic/widget.tsx": [ + "_next/static/widget.js", + "_next/static/widget.css", + "_next/static/widget-helper.js", + ], + }); + }); + + it("does not pull nested dynamic imports into the parent boundary", () => { + const manifest = { + "app/page.tsx": { + file: "_next/static/page.js", + isEntry: true, + dynamicImports: ["app/dynamic/chart.tsx"], + }, + "app/dynamic/chart.tsx": { + file: "_next/static/chart.js", + isDynamicEntry: true, + dynamicImports: ["app/dynamic/heavy-vendor.ts"], + }, + "app/dynamic/heavy-vendor.ts": { + file: "_next/static/heavy-vendor.js", + isDynamicEntry: true, + }, + }; + + expect(computeDynamicImportPreloads(manifest)).toEqual({ + "app/dynamic/chart.tsx": ["_next/static/chart.js"], + "app/dynamic/heavy-vendor.ts": ["_next/static/heavy-vendor.js"], + }); + }); +}); + +describe("next/dynamic preload metadata transform", () => { + const root = path.resolve("/repo"); + const importer = path.join(root, "app/page.tsx"); + const resolveDynamicImport = async (specifier: string) => + specifier === "./dynamic-widget" + ? path.join(root, "app/dynamic-widget.tsx") + : specifier === "./named" + ? path.join(root, "app/named.tsx") + : null; + + it("adds loadableGenerated modules to dynamic loader calls", async () => { + const result = await _transformNextDynamicPreloadMetadata( + [ + `import dynamic from "next/dynamic";`, + `const Widget = dynamic(() => import("./dynamic-widget"), { loading: Loading });`, + ].join("\n"), + importer, + root, + resolveDynamicImport, + ); + + expect(result?.code).toContain(`loadableGenerated: { modules: ["app/dynamic-widget.tsx"] }`); + }); + + it("preserves existing explicit loadableGenerated metadata", async () => { + const code = [ + `import dynamic from "next/dynamic";`, + `const Widget = dynamic(() => import("./dynamic-widget"), { loadableGenerated: { modules: ["custom"] } });`, + ].join("\n"); + const result = await _transformNextDynamicPreloadMetadata( + code, + importer, + root, + resolveDynamicImport, + ); + + expect(result).toBeNull(); + }); + + it("supports the object loader form", async () => { + const result = await _transformNextDynamicPreloadMetadata( + [ + `import dynamic from "next/dynamic";`, + `const Widget = dynamic({ loader: () => import("./named"), ssr: true });`, + ].join("\n"), + importer, + root, + resolveDynamicImport, + ); + + expect(result?.code).toContain(`loadableGenerated: { modules: ["app/named.tsx"] }`); + }); +}); + describe("augmentSsrManifestFromBundle", () => { it("backfills inlined page modules with the containing entry chunk", () => { const bundle = { diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index 21c41578a..95753457a 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -29,7 +29,11 @@ import { } from "../packages/vinext/src/utils/project.js"; import { manifestFileWithBase } from "../packages/vinext/src/utils/manifest-paths.js"; import { scanPublicFileRoutes } from "../packages/vinext/src/utils/public-routes.js"; -import { computeLazyChunks } from "../packages/vinext/src/utils/lazy-chunks.js"; +import { + computeDynamicImportPreloads, + computeLazyChunks, + dynamicImportPreloadsWithBase, +} from "../packages/vinext/src/utils/lazy-chunks.js"; import { mergeHeaders, resolveStaticAssetSignal, @@ -1931,8 +1935,9 @@ describe("Cloudflare closeBundle lazy chunk injection", () => { const clientDir = path.resolve(buildRoot, "dist", "client"); - // Read build manifest and compute lazy chunks + // Read build manifest and compute dynamic chunk metadata let lazyChunksData: string[] | null = null; + let dynamicPreloadsData: Record | null = null; const buildManifestPath = path.join(clientDir, ".vite", "manifest.json"); if (fs.existsSync(buildManifestPath)) { try { @@ -1941,6 +1946,11 @@ describe("Cloudflare closeBundle lazy chunk injection", () => { manifestFileWithBase(file, base), ); if (lazy.length > 0) lazyChunksData = lazy; + const dynamicPreloads = dynamicImportPreloadsWithBase( + computeDynamicImportPreloads(buildManifest), + (file) => manifestFileWithBase(file, base), + ); + if (Object.keys(dynamicPreloads).length > 0) dynamicPreloadsData = dynamicPreloads; } catch { /* ignore */ } @@ -1959,7 +1969,7 @@ describe("Cloudflare closeBundle lazy chunk injection", () => { // App Router: inject into dist/server/index.js (NOT __VINEXT_CLIENT_ENTRY__) const workerEntry = path.resolve(distDir, "server", "index.js"); - if (fs.existsSync(workerEntry) && (lazyChunksData || ssrManifestData)) { + if (fs.existsSync(workerEntry) && (lazyChunksData || dynamicPreloadsData || ssrManifestData)) { let code = fs.readFileSync(workerEntry, "utf-8"); const globals: string[] = []; if (ssrManifestData) { @@ -1968,6 +1978,11 @@ describe("Cloudflare closeBundle lazy chunk injection", () => { if (lazyChunksData) { globals.push(`globalThis.__VINEXT_LAZY_CHUNKS__ = ${JSON.stringify(lazyChunksData)};`); } + if (dynamicPreloadsData) { + globals.push( + `globalThis.__VINEXT_DYNAMIC_PRELOADS__ = ${JSON.stringify(dynamicPreloadsData)};`, + ); + } code = globals.join("\n") + "\n" + code; fs.writeFileSync(workerEntry, code); } @@ -2002,8 +2017,9 @@ describe("Cloudflare closeBundle lazy chunk injection", () => { const workerEntry = path.join(workerOutDir, "index.js"); if (!fs.existsSync(workerEntry)) return; - // Read build manifest and compute lazy chunks + // Read build manifest and compute dynamic chunk metadata let lazyChunksData: string[] | null = null; + let dynamicPreloadsData: Record | null = null; let clientEntryFile: string | null = null; const buildManifestPath = path.join(clientDir, ".vite", "manifest.json"); if (fs.existsSync(buildManifestPath)) { @@ -2019,6 +2035,11 @@ describe("Cloudflare closeBundle lazy chunk injection", () => { manifestFileWithBase(file, base), ); if (lazy.length > 0) lazyChunksData = lazy; + const dynamicPreloads = dynamicImportPreloadsWithBase( + computeDynamicImportPreloads(buildManifest), + (file) => manifestFileWithBase(file, base), + ); + if (Object.keys(dynamicPreloads).length > 0) dynamicPreloadsData = dynamicPreloads; } catch { /* ignore */ } @@ -2036,7 +2057,7 @@ describe("Cloudflare closeBundle lazy chunk injection", () => { } // Pages Router: inject all three globals - if (clientEntryFile || ssrManifestData || lazyChunksData) { + if (clientEntryFile || ssrManifestData || lazyChunksData || dynamicPreloadsData) { let code = fs.readFileSync(workerEntry, "utf-8"); const globals: string[] = []; if (clientEntryFile) { @@ -2048,6 +2069,11 @@ describe("Cloudflare closeBundle lazy chunk injection", () => { if (lazyChunksData) { globals.push(`globalThis.__VINEXT_LAZY_CHUNKS__ = ${JSON.stringify(lazyChunksData)};`); } + if (dynamicPreloadsData) { + globals.push( + `globalThis.__VINEXT_DYNAMIC_PRELOADS__ = ${JSON.stringify(dynamicPreloadsData)};`, + ); + } code = globals.join("\n") + "\n" + code; fs.writeFileSync(workerEntry, code); } @@ -2155,6 +2181,19 @@ describe("Cloudflare closeBundle lazy chunk injection", () => { expect(lazyChunks).not.toContain("assets/framework.js"); }); + it("App Router: injects __VINEXT_DYNAMIC_PRELOADS__ into dist/server/index.js", () => { + setupAppRouterBuildOutput(tmpDir, manifestWithLazyChunks); + + simulateCloseBundleAppRouter(tmpDir); + + const code = fs.readFileSync(path.join(tmpDir, "dist", "server", "index.js"), "utf-8"); + expect(code).toContain("globalThis.__VINEXT_DYNAMIC_PRELOADS__"); + expect(code).toContain( + `"src/components/MermaidChart.tsx":["assets/mermaid-chart.js","assets/mermaid-vendor.js"]`, + ); + expect(code).not.toContain(`"virtual:vinext-app-browser-entry"`); + }); + it("App Router: does NOT inject __VINEXT_CLIENT_ENTRY__", () => { setupAppRouterBuildOutput(tmpDir, manifestWithLazyChunks); @@ -2206,8 +2245,10 @@ describe("Cloudflare closeBundle lazy chunk injection", () => { simulateCloseBundleAppRouter(tmpDir); const code = fs.readFileSync(path.join(tmpDir, "dist", "server", "index.js"), "utf-8"); - // No globals should be injected since there are no lazy chunks and no SSR manifest + // No globals should be injected since there are no lazy chunks, no dynamic + // preload entries, and no SSR manifest expect(code).not.toContain("globalThis.__VINEXT_LAZY_CHUNKS__"); + expect(code).not.toContain("globalThis.__VINEXT_DYNAMIC_PRELOADS__"); expect(code).not.toContain("globalThis.__VINEXT_SSR_MANIFEST__"); // Original code untouched expect(code).toBe("// RSC worker entry\nexport default { fetch() {} };"); @@ -2227,7 +2268,7 @@ describe("Cloudflare closeBundle lazy chunk injection", () => { // ── Pages Router tests ──────────────────────────────────────────────── - it("Pages Router: injects all three globals into worker entry", () => { + it("Pages Router: injects all runtime globals into worker entry", () => { const ssrManifest = { "pages/index.tsx": ["/assets/page-index.js", "/assets/page-index.css"], }; @@ -2239,6 +2280,7 @@ describe("Cloudflare closeBundle lazy chunk injection", () => { expect(code).toContain("globalThis.__VINEXT_CLIENT_ENTRY__"); expect(code).toContain("globalThis.__VINEXT_SSR_MANIFEST__"); expect(code).toContain("globalThis.__VINEXT_LAZY_CHUNKS__"); + expect(code).toContain("globalThis.__VINEXT_DYNAMIC_PRELOADS__"); }); it("Pages Router: injects correct lazy chunks", () => { @@ -2256,6 +2298,18 @@ describe("Cloudflare closeBundle lazy chunk injection", () => { expect(lazyChunks).not.toContain("assets/framework.js"); }); + it("Pages Router: injects __VINEXT_DYNAMIC_PRELOADS__ into worker entry", () => { + setupPagesRouterBuildOutput(tmpDir, manifestWithLazyChunks); + + simulateCloseBundlePagesRouter(tmpDir); + + const code = fs.readFileSync(path.join(tmpDir, "dist", "worker", "index.js"), "utf-8"); + expect(code).toContain("globalThis.__VINEXT_DYNAMIC_PRELOADS__"); + expect(code).toContain( + `"src/components/MermaidChart.tsx":["assets/mermaid-chart.js","assets/mermaid-vendor.js"]`, + ); + }); + it("Pages Router: prefixes client entry and lazy chunks with basePath", () => { setupPagesRouterBuildOutput(tmpDir, manifestWithLazyChunks); @@ -2269,6 +2323,9 @@ describe("Cloudflare closeBundle lazy chunk injection", () => { const lazyChunks = JSON.parse(match![1]); expect(lazyChunks).toContain("docs/assets/mermaid-chart.js"); expect(lazyChunks).toContain("docs/assets/mermaid-vendor.js"); + expect(code).toContain( + `"src/components/MermaidChart.tsx":["docs/assets/mermaid-chart.js","docs/assets/mermaid-vendor.js"]`, + ); }); it("Pages Router: finds worker entry via wrangler.json directory scan", () => { diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts index 66f3a8123..a75ed0a3f 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -2407,9 +2407,9 @@ export const config = { matcher: ["/protected"] }; }, }); - // Verify client output exists under Next.js's canonical `_next/static/` - // directory (matches `resolveAssetsDir("")`). - const assetsDir = path.join(outDir, "client", "_next", "static"); + // Verify client JS output exists under Next.js's canonical + // `_next/static/chunks/` directory. + const assetsDir = path.join(outDir, "client", "_next", "static", "chunks"); expect(fs.existsSync(assetsDir)).toBe(true); // Verify SSR manifest was produced From f3dca36a5d8cba58d8559f750055499f778afc4d Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Tue, 26 May 2026 03:45:45 +1000 Subject: [PATCH 02/14] fix: honor assetPrefix for dynamic preload globals --- packages/vinext/src/index.ts | 8 ++- packages/vinext/src/server/prod-server.ts | 16 +++-- packages/vinext/src/utils/manifest-paths.ts | 31 +++++++++- tests/asset-prefix.test.ts | 46 +++++++++++++- tests/deploy.test.ts | 66 ++++++++++++++++++--- 5 files changed, 147 insertions(+), 20 deletions(-) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 41274e13b..a5d0da254 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -80,7 +80,7 @@ import { import { scanMetadataFiles } from "./server/metadata-routes.js"; import { buildRequestHeadersFromMiddlewareResponse } from "./server/middleware-request-headers.js"; import { detectPackageManager } from "./utils/project.js"; -import { manifestFileWithBase, manifestFilesWithBase } from "./utils/manifest-paths.js"; +import { manifestFileWithAssetPrefix, manifestFileWithBase } from "./utils/manifest-paths.js"; import { hasBasePath } from "./utils/base-path.js"; import { mergeRewriteQuery } from "./utils/query.js"; import { @@ -4106,11 +4106,13 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { break; } } - const lazy = manifestFilesWithBase(computeLazyChunks(buildManifest), clientBase); + const lazy = computeLazyChunks(buildManifest).map((file) => + manifestFileWithAssetPrefix(file, clientBase, nextConfig.assetPrefix), + ); if (lazy.length > 0) lazyChunksData = lazy; const dynamicPreloads = dynamicImportPreloadsWithBase( computeDynamicImportPreloads(buildManifest), - (file) => manifestFileWithBase(file, clientBase), + (file) => manifestFileWithAssetPrefix(file, clientBase, nextConfig.assetPrefix), ); if (Object.keys(dynamicPreloads).length > 0) { dynamicPreloadsData = dynamicPreloads; diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index 8cb6d7bcf..0a4bf7b0a 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -70,7 +70,7 @@ import { computeLazyChunks, dynamicImportPreloadsWithBase, } from "../utils/lazy-chunks.js"; -import { manifestFileWithBase } from "../utils/manifest-paths.js"; +import { manifestFileWithAssetPrefix } from "../utils/manifest-paths.js"; import { normalizePathnameForRouteMatchStrict } from "../routing/utils.js"; import type { ExecutionContextLike } from "vinext/shims/request-context"; import { readPrerenderSecret } from "../build/server-manifest.js"; @@ -261,7 +261,11 @@ function stripHeaders( } } -function installClientBuildManifestGlobals(clientDir: string, assetBase: string): void { +function installClientBuildManifestGlobals( + clientDir: string, + assetBase: string, + assetPrefix: string, +): void { globalThis.__VINEXT_LAZY_CHUNKS__ = undefined; globalThis.__VINEXT_DYNAMIC_PRELOADS__ = undefined; @@ -271,7 +275,7 @@ function installClientBuildManifestGlobals(clientDir: string, assetBase: string) try { const buildManifest = JSON.parse(fs.readFileSync(buildManifestPath, "utf-8")); const lazyChunks = computeLazyChunks(buildManifest).map((file: string) => - manifestFileWithBase(file, assetBase), + manifestFileWithAssetPrefix(file, assetBase, assetPrefix), ); if (lazyChunks.length > 0) { globalThis.__VINEXT_LAZY_CHUNKS__ = lazyChunks; @@ -279,7 +283,7 @@ function installClientBuildManifestGlobals(clientDir: string, assetBase: string) const dynamicPreloads = dynamicImportPreloadsWithBase( computeDynamicImportPreloads(buildManifest), - (file) => manifestFileWithBase(file, assetBase), + (file) => manifestFileWithAssetPrefix(file, assetBase, assetPrefix), ); if (Object.keys(dynamicPreloads).length > 0) { globalThis.__VINEXT_DYNAMIC_PRELOADS__ = dynamicPreloads; @@ -1120,7 +1124,7 @@ async function startAppRouterServer(options: AppRouterServerOptions) { // before locating files on disk includes this path plus `_next/static/`. const appAssetPathPrefix = assetPrefixPathname(appRouterAssetPrefix); const appAssetBase = appRouterBasePath ? `${appRouterBasePath}/` : "/"; - installClientBuildManifestGlobals(clientDir, appAssetBase); + installClientBuildManifestGlobals(clientDir, appAssetBase, appRouterAssetPrefix); // Seed the memory cache with pre-rendered routes so the first request to // any pre-rendered page is a cache HIT instead of a full re-render. @@ -1445,7 +1449,7 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { // Load the build manifest to compute lazy chunks and rendered next/dynamic // preload files. These globals are read by shims during SSR. - installClientBuildManifestGlobals(clientDir, assetBase); + installClientBuildManifestGlobals(clientDir, assetBase, assetPrefix); // Build the static file metadata cache at startup (same as App Router). const staticCache = await StaticFileCache.create(clientDir); diff --git a/packages/vinext/src/utils/manifest-paths.ts b/packages/vinext/src/utils/manifest-paths.ts index 61af3458f..bc5ea5913 100644 --- a/packages/vinext/src/utils/manifest-paths.ts +++ b/packages/vinext/src/utils/manifest-paths.ts @@ -1,3 +1,10 @@ +import { + ASSET_PREFIX_URL_DIR, + isAbsoluteAssetPrefix, + resolveAssetsDir, + resolveAssetUrlPrefix, +} from "./asset-prefix.js"; + export function normalizeManifestFile(file: string): string { return file.startsWith("/") ? file.slice(1) : file; } @@ -14,6 +21,26 @@ export function manifestFileWithBase(file: string, base: string): string { return normalizedBase + "/" + normalizedFile; } -export function manifestFilesWithBase(files: string[], base: string): string[] { - return files.map((file) => manifestFileWithBase(file, base)); +export function manifestFileWithAssetPrefix( + file: string, + base: string, + assetPrefix: string, +): string { + if (!assetPrefix) return manifestFileWithBase(file, base); + + const normalizedFile = normalizeManifestFile(file); + const onDiskDir = normalizeManifestFile(resolveAssetsDir(assetPrefix)); + const onDiskDirPrefix = onDiskDir + "/"; + const staticDirPrefix = ASSET_PREFIX_URL_DIR + "/"; + const stripped = normalizedFile.startsWith(onDiskDirPrefix) + ? normalizedFile.slice(onDiskDirPrefix.length) + : normalizedFile.startsWith(staticDirPrefix) + ? normalizedFile.slice(staticDirPrefix.length) + : normalizedFile; + + const urlPrefix = resolveAssetUrlPrefix(assetPrefix); + const normalizedUrlPrefix = isAbsoluteAssetPrefix(assetPrefix) + ? urlPrefix + : normalizeManifestFile(urlPrefix); + return normalizedUrlPrefix + stripped; } diff --git a/tests/asset-prefix.test.ts b/tests/asset-prefix.test.ts index a0bcbb566..839df8060 100644 --- a/tests/asset-prefix.test.ts +++ b/tests/asset-prefix.test.ts @@ -35,6 +35,7 @@ import { assetPrefixPathname, ASSET_PREFIX_URL_DIR, } from "../packages/vinext/src/utils/asset-prefix.js"; +import { manifestFileWithAssetPrefix } from "../packages/vinext/src/utils/manifest-paths.js"; import { resolveAppRouterAssetPath } from "../packages/vinext/src/server/prod-server.js"; import { normalizeAssetPrefix } from "../packages/vinext/src/config/next-config.js"; @@ -130,6 +131,36 @@ describe("resolveAssetUrlPrefix", () => { }); }); +describe("manifestFileWithAssetPrefix", () => { + it("uses basePath-compatible base when assetPrefix is unset", () => { + expect(manifestFileWithAssetPrefix("_next/static/chunks/page.js", "/docs/", "")).toBe( + "docs/_next/static/chunks/page.js", + ); + }); + + it("anchors unprefixed manifest files under a path assetPrefix", () => { + expect(manifestFileWithAssetPrefix("_next/static/chunks/page.js", "/", "/cdn")).toBe( + "cdn/_next/static/chunks/page.js", + ); + }); + + it("does not double-prefix files already emitted under a path assetPrefix", () => { + expect(manifestFileWithAssetPrefix("cdn/_next/static/chunks/page.js", "/docs/", "/cdn")).toBe( + "cdn/_next/static/chunks/page.js", + ); + }); + + it("anchors manifest files under an absolute assetPrefix", () => { + expect( + manifestFileWithAssetPrefix( + "_next/static/chunks/page.js", + "/docs/", + "https://cdn.example.com/assets", + ), + ).toBe("https://cdn.example.com/assets/_next/static/chunks/page.js"); + }); +}); + describe("assetPrefixPathname", () => { it("returns empty for unset prefix and absolute URLs without a path component", () => { expect(assetPrefixPathname("")).toBe(""); @@ -292,6 +323,18 @@ async function buildFixtureWithConfig( return { fixtureRoot, outDir }; } +function directoryContainsFileWithExtension(dir: string, extension: string): boolean { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (directoryContainsFileWithExtension(entryPath, extension)) return true; + continue; + } + if (entry.isFile() && entry.name.endsWith(extension)) return true; + } + return false; +} + describe("assetPrefix end-to-end build", () => { // Track tmp dirs so we can clean up even if a build throws. `cleanups` is // populated by `buildFixtureWithConfig` synchronously right after the tmp @@ -317,8 +360,7 @@ describe("assetPrefix end-to-end build", () => { "static", ); expect(fs.existsSync(onDiskStatic), `expected on-disk layout under ${onDiskStatic}`).toBe(true); - const entries = fs.readdirSync(onDiskStatic); - expect(entries.some((f) => f.endsWith(".js"))).toBe(true); + expect(directoryContainsFileWithExtension(onDiskStatic, ".js")).toBe(true); // Serve the build via startProdServer and verify SSR HTML references // the assetPrefix-anchored URLs, and that those URLs return 200. diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index 95753457a..2c65bae92 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -27,7 +27,10 @@ import { findInNodeModules, ensureViteConfigCompatibility, } from "../packages/vinext/src/utils/project.js"; -import { manifestFileWithBase } from "../packages/vinext/src/utils/manifest-paths.js"; +import { + manifestFileWithAssetPrefix, + manifestFileWithBase, +} from "../packages/vinext/src/utils/manifest-paths.js"; import { scanPublicFileRoutes } from "../packages/vinext/src/utils/public-routes.js"; import { computeDynamicImportPreloads, @@ -1929,7 +1932,7 @@ describe("Cloudflare closeBundle lazy chunk injection", () => { * is at dist/server/index.js. The RSC plugin handles __VINEXT_CLIENT_ENTRY__, * but we still need to inject __VINEXT_LAZY_CHUNKS__ and __VINEXT_SSR_MANIFEST__. */ - function simulateCloseBundleAppRouter(buildRoot: string, base = "/"): void { + function simulateCloseBundleAppRouter(buildRoot: string, base = "/", assetPrefix = ""): void { const distDir = path.resolve(buildRoot, "dist"); if (!fs.existsSync(distDir)) return; @@ -1943,12 +1946,12 @@ describe("Cloudflare closeBundle lazy chunk injection", () => { try { const buildManifest = JSON.parse(fs.readFileSync(buildManifestPath, "utf-8")); const lazy = computeLazyChunks(buildManifest).map((file) => - manifestFileWithBase(file, base), + manifestFileWithAssetPrefix(file, base, assetPrefix), ); if (lazy.length > 0) lazyChunksData = lazy; const dynamicPreloads = dynamicImportPreloadsWithBase( computeDynamicImportPreloads(buildManifest), - (file) => manifestFileWithBase(file, base), + (file) => manifestFileWithAssetPrefix(file, base, assetPrefix), ); if (Object.keys(dynamicPreloads).length > 0) dynamicPreloadsData = dynamicPreloads; } catch { @@ -1993,7 +1996,7 @@ describe("Cloudflare closeBundle lazy chunk injection", () => { * The worker entry is found by scanning dist/ for a directory containing * wrangler.json. All three globals are injected. */ - function simulateCloseBundlePagesRouter(buildRoot: string, base = "/"): void { + function simulateCloseBundlePagesRouter(buildRoot: string, base = "/", assetPrefix = ""): void { const distDir = path.resolve(buildRoot, "dist"); if (!fs.existsSync(distDir)) return; @@ -2032,12 +2035,12 @@ describe("Cloudflare closeBundle lazy chunk injection", () => { } } const lazy = computeLazyChunks(buildManifest).map((file) => - manifestFileWithBase(file, base), + manifestFileWithAssetPrefix(file, base, assetPrefix), ); if (lazy.length > 0) lazyChunksData = lazy; const dynamicPreloads = dynamicImportPreloadsWithBase( computeDynamicImportPreloads(buildManifest), - (file) => manifestFileWithBase(file, base), + (file) => manifestFileWithAssetPrefix(file, base, assetPrefix), ); if (Object.keys(dynamicPreloads).length > 0) dynamicPreloadsData = dynamicPreloads; } catch { @@ -2194,6 +2197,38 @@ describe("Cloudflare closeBundle lazy chunk injection", () => { expect(code).not.toContain(`"virtual:vinext-app-browser-entry"`); }); + it("App Router: prefixes lazy chunks and dynamic preloads with assetPrefix", () => { + setupAppRouterBuildOutput(tmpDir, manifestWithLazyChunks); + + simulateCloseBundleAppRouter(tmpDir, "/docs/", "/cdn-prefix"); + + const code = fs.readFileSync(path.join(tmpDir, "dist", "server", "index.js"), "utf-8"); + const lazyMatch = code.match(/globalThis\.__VINEXT_LAZY_CHUNKS__\s*=\s*(\[.*?\]);/); + expect(lazyMatch).not.toBeNull(); + const lazyChunks = JSON.parse(lazyMatch![1]); + expect(lazyChunks).toContain("cdn-prefix/_next/static/assets/mermaid-chart.js"); + expect(lazyChunks).toContain("cdn-prefix/_next/static/assets/mermaid-vendor.js"); + expect(lazyChunks).not.toContain("docs/assets/mermaid-chart.js"); + + const preloadMatch = code.match(/globalThis\.__VINEXT_DYNAMIC_PRELOADS__\s*=\s*(\{.*?\});/); + expect(preloadMatch).not.toBeNull(); + const dynamicPreloads = JSON.parse(preloadMatch![1]); + expect(dynamicPreloads["src/components/MermaidChart.tsx"]).toEqual([ + "cdn-prefix/_next/static/assets/mermaid-chart.js", + "cdn-prefix/_next/static/assets/mermaid-vendor.js", + ]); + }); + + it("App Router: emits absolute assetPrefix URLs in dynamic preload globals", () => { + setupAppRouterBuildOutput(tmpDir, manifestWithLazyChunks); + + simulateCloseBundleAppRouter(tmpDir, "/docs/", "https://cdn.example.com/assets"); + + const code = fs.readFileSync(path.join(tmpDir, "dist", "server", "index.js"), "utf-8"); + expect(code).toContain("https://cdn.example.com/assets/_next/static/assets/mermaid-chart.js"); + expect(code).not.toContain("docs/assets/mermaid-chart.js"); + }); + it("App Router: does NOT inject __VINEXT_CLIENT_ENTRY__", () => { setupAppRouterBuildOutput(tmpDir, manifestWithLazyChunks); @@ -2328,6 +2363,23 @@ describe("Cloudflare closeBundle lazy chunk injection", () => { ); }); + it("Pages Router: prefixes lazy chunks and dynamic preloads with assetPrefix", () => { + setupPagesRouterBuildOutput(tmpDir, manifestWithLazyChunks); + + simulateCloseBundlePagesRouter(tmpDir, "/docs/", "/cdn-prefix"); + + const code = fs.readFileSync(path.join(tmpDir, "dist", "worker", "index.js"), "utf-8"); + const lazyMatch = code.match(/globalThis\.__VINEXT_LAZY_CHUNKS__\s*=\s*(\[.*?\]);/); + expect(lazyMatch).not.toBeNull(); + const lazyChunks = JSON.parse(lazyMatch![1]); + expect(lazyChunks).toContain("cdn-prefix/_next/static/assets/mermaid-chart.js"); + expect(lazyChunks).toContain("cdn-prefix/_next/static/assets/mermaid-vendor.js"); + expect(lazyChunks).not.toContain("docs/assets/mermaid-chart.js"); + expect(code).toContain( + `"src/components/MermaidChart.tsx":["cdn-prefix/_next/static/assets/mermaid-chart.js","cdn-prefix/_next/static/assets/mermaid-vendor.js"]`, + ); + }); + it("Pages Router: finds worker entry via wrangler.json directory scan", () => { setupPagesRouterBuildOutput(tmpDir, manifestWithLazyChunks); From df7e4499fc6713182d808465d45706efa1041324 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Tue, 26 May 2026 11:33:54 +1000 Subject: [PATCH 03/14] test: cover asset-prefixed dynamic preload nonces App Router production only covered the default dynamic preload URL path. That left assetPrefix regressions at the manifest-to-runtime emission boundary unguarded. Add a startProdServer regression that builds app-basic with assetPrefix "/cdn" and asserts nonce-bearing next/dynamic chunk preloads render under /cdn/_next/static/chunks/. --- tests/app-router.test.ts | 72 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index 003a64cbb..eed840963 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -2384,6 +2384,78 @@ describe("App Router Production server (startProdServer)", () => { } }); + it("preloads rendered next/dynamic chunks with assetPrefix and the CSP nonce", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-app-dynamic-asset-prefix-")); + const fixtureRoot = path.join(tmpDir, "fixture"); + const prefixedOutDir = path.join(fixtureRoot, "dist"); + let assetPrefixServer: import("node:http").Server | undefined; + + try { + fs.cpSync(APP_FIXTURE_DIR, fixtureRoot, { recursive: true }); + fs.rmSync(prefixedOutDir, { recursive: true, force: true }); + const fixtureNodeModules = path.join(fixtureRoot, "node_modules"); + if (!fs.existsSync(fixtureNodeModules)) { + fs.symlinkSync( + path.resolve(__dirname, "..", "node_modules"), + fixtureNodeModules, + "junction", + ); + } + + const nextConfigPath = path.join(fixtureRoot, "next.config.ts"); + const nextConfig = fs.readFileSync(nextConfigPath, "utf-8"); + fs.writeFileSync( + nextConfigPath, + nextConfig.replace( + "const nextConfig: NextConfig = {", + 'const nextConfig: NextConfig = {\n assetPrefix: "/cdn",', + ), + ); + + const builder = await createBuilder({ + root: fixtureRoot, + configFile: false, + plugins: [vinext({ appDir: fixtureRoot })], + logLevel: "silent", + }); + await builder.buildApp(); + + const { startProdServer } = await import("../packages/vinext/src/server/prod-server.js"); + ({ server: assetPrefixServer } = await startProdServer({ + port: 0, + outDir: prefixedOutDir, + noCompression: true, + })); + const addr = assetPrefixServer.address(); + const port = typeof addr === "object" && addr ? addr.port : 0; + const tmpBaseUrl = `http://localhost:${port}`; + + const res = await fetch(`${tmpBaseUrl}/nextjs-compat/dynamic?csp-nonce=1`); + expect(res.status).toBe(200); + expect(res.headers.get("content-security-policy")).toBe( + "script-src 'nonce-vinext-test-nonce' 'strict-dynamic';", + ); + + const html = await res.text(); + // DynamicPreloadChunks emits ReactDOM.preload(..., { fetchPriority: "low" }); + // use that signal to avoid matching route bootstrap modulepreload links. + const dynamicScriptPreloads = (html.match(/]*>/g) ?? []).filter( + (tag) => + tag.includes('fetchPriority="low"') && + tag.includes('nonce="vinext-test-nonce"') && + tag.includes("/_next/static/chunks/"), + ); + + expect(dynamicScriptPreloads.length).toBeGreaterThan(0); + for (const tag of dynamicScriptPreloads) { + expect(tag).toMatch(/\bhref="\/cdn\/_next\/static\/chunks\/[^"]+\.js"/); + } + } finally { + assetPrefixServer?.close(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }, 60000); + it("does not collapse encoded slashes onto nested routes in production", async () => { const encodedRes = await fetch(`${baseUrl}/headers%2Foverride-from-middleware`); expect(encodedRes.status).toBe(404); From 8c2a1b7d8fa2c1cba4023fc94701b3da94997f8a Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Tue, 26 May 2026 11:43:54 +1000 Subject: [PATCH 04/14] test: isolate asset-prefixed prod server globals The assetPrefix regression starts a second production server in the same Vitest process. startProdServer and the generated RSC runtime install global manifest and module-loader state, so leaving the temporary build's globals in place breaks later production requests in the shared server. Snapshot and restore the relevant globals around the temporary /cdn server while keeping the integration assertion at the real runtime boundary. --- tests/app-router.test.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index eed840963..ed643beb3 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -2389,6 +2389,29 @@ describe("App Router Production server (startProdServer)", () => { const fixtureRoot = path.join(tmpDir, "fixture"); const prefixedOutDir = path.join(fixtureRoot, "dist"); let assetPrefixServer: import("node:http").Server | undefined; + const prodGlobalKeys = [ + "__VINEXT_CLIENT_ENTRY__", + "__VINEXT_DYNAMIC_PRELOADS__", + "__VINEXT_LAZY_CHUNKS__", + "__VINEXT_SSR_MANIFEST__", + "__vite_rsc_client_require__", + "__vite_rsc_require__", + "__vite_rsc_server_require__", + "__webpack_chunk_load__", + "__webpack_require__", + ]; + // startProdServer installs build/runtime globals process-wide. This test + // starts a second prod server while the shared server is still alive, so + // restore the shared server's globals before later tests run. + const previousGlobals = new Map( + prodGlobalKeys.map((key) => [ + key, + { + exists: Reflect.has(globalThis, key), + value: Reflect.get(globalThis, key), + }, + ]), + ); try { fs.cpSync(APP_FIXTURE_DIR, fixtureRoot, { recursive: true }); @@ -2452,6 +2475,13 @@ describe("App Router Production server (startProdServer)", () => { } } finally { assetPrefixServer?.close(); + for (const [key, previous] of previousGlobals) { + if (previous.exists) { + Reflect.set(globalThis, key, previous.value); + } else { + Reflect.deleteProperty(globalThis, key); + } + } fs.rmSync(tmpDir, { recursive: true, force: true }); } }, 60000); From 1b561f10aab5ba032ba8ba408c4ce3ea6617baf8 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Tue, 26 May 2026 13:31:17 +1000 Subject: [PATCH 05/14] refactor: reuse shared record guard in dynamic preload metadata The dynamic preload metadata transform carried a local object guard even though the codebase already has a shared record predicate. That made the AST helper drift from the existing non-array record invariant used elsewhere. Reuse isUnknownRecord from utils/record and keep the plugin-specific AST alias local to the transform. --- packages/vinext/src/plugins/dynamic-preload-metadata.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/vinext/src/plugins/dynamic-preload-metadata.ts b/packages/vinext/src/plugins/dynamic-preload-metadata.ts index 03c1dc10d..b5e8fc58a 100644 --- a/packages/vinext/src/plugins/dynamic-preload-metadata.ts +++ b/packages/vinext/src/plugins/dynamic-preload-metadata.ts @@ -2,10 +2,9 @@ import type { Plugin } from "vite"; import { parseAst } from "vite"; import MagicString from "magic-string"; import path from "node:path"; +import { isUnknownRecord as isRecord } from "../utils/record.js"; -type AstRecord = { - [key: string]: unknown; -}; +type AstRecord = Record; type TransformResult = { code: string; @@ -14,10 +13,6 @@ type TransformResult = { type ResolveDynamicImport = (specifier: string, importer: string) => Promise; -function isRecord(value: unknown): value is AstRecord { - return !!value && typeof value === "object"; -} - function getString(node: AstRecord, key: string): string | null { const value = node[key]; return typeof value === "string" ? value : null; From 44344055ea2c31c9e106fe899a8ca88996c063a3 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Tue, 26 May 2026 16:15:30 +1000 Subject: [PATCH 06/14] fix: make dynamic preload metadata binding-aware The next/dynamic metadata transform matched calls by imported identifier text. Shadowed parameters and block bindings with the same name were therefore mutated even when they no longer referred to the next/dynamic import. Walk the parsed module with lexical binding state so shadowed names are excluded, and collect object-form imports only from loader/modules rather than every property on the options object. --- .../src/plugins/dynamic-preload-metadata.ts | 231 +++++++++++++++++- tests/build-optimization.test.ts | 86 ++++++- 2 files changed, 313 insertions(+), 4 deletions(-) diff --git a/packages/vinext/src/plugins/dynamic-preload-metadata.ts b/packages/vinext/src/plugins/dynamic-preload-metadata.ts index b5e8fc58a..6f9d90b68 100644 --- a/packages/vinext/src/plugins/dynamic-preload-metadata.ts +++ b/packages/vinext/src/plugins/dynamic-preload-metadata.ts @@ -28,6 +28,10 @@ function getArray(node: AstRecord, key: string): unknown[] { return Array.isArray(value) ? value : []; } +function getBoolean(node: AstRecord, key: string): boolean { + return node[key] === true; +} + function nodeName(node: unknown): string | null { if (!isRecord(node)) return null; const name = node.name; @@ -98,6 +102,216 @@ function isDynamicCall(node: AstRecord, dynamicLocals: Set): boolean { return isIdentifierNamed(node.callee, dynamicLocals); } +function addBindingName(pattern: unknown, names: Set): void { + if (!isRecord(pattern)) return; + + const type = getString(pattern, "type"); + if (type === null) return; + + switch (type) { + case "Identifier": { + const name = getString(pattern, "name"); + if (name) names.add(name); + return; + } + case "AssignmentPattern": + addBindingName(pattern.left, names); + return; + case "RestElement": + addBindingName(pattern.argument, names); + return; + case "ArrayPattern": + for (const element of getArray(pattern, "elements")) { + addBindingName(element, names); + } + return; + case "ObjectPattern": + for (const property of getArray(pattern, "properties")) { + if (!isRecord(property)) continue; + if (getString(property, "type") === "RestElement") { + addBindingName(property.argument, names); + continue; + } + addBindingName(property.value, names); + } + return; + default: + return; + } +} + +function addVariableDeclarationBindingNames(node: unknown, names: Set): void { + if (!isRecord(node) || getString(node, "type") !== "VariableDeclaration") return; + for (const declaration of getArray(node, "declarations")) { + if (isRecord(declaration)) addBindingName(declaration.id, names); + } +} + +function collectBlockScopedBindingNames(body: readonly unknown[]): Set { + const names = new Set(); + + for (const statement of body) { + if (!isRecord(statement)) continue; + + const type = getString(statement, "type"); + if (type === "VariableDeclaration") { + if (getString(statement, "kind") !== "var") { + addVariableDeclarationBindingNames(statement, names); + } + continue; + } + + if (type === "FunctionDeclaration" || type === "ClassDeclaration") { + const name = nodeName(statement.id); + if (name) names.add(name); + } + } + + return names; +} + +function collectVarBindingNames(value: unknown, names: Set): void { + if (!isRecord(value)) return; + + const type = getString(value, "type"); + if ( + type === "FunctionDeclaration" || + type === "FunctionExpression" || + type === "ArrowFunctionExpression" + ) { + return; + } + + if (type === "VariableDeclaration" && getString(value, "kind") === "var") { + addVariableDeclarationBindingNames(value, names); + } + + for (const [key, child] of Object.entries(value)) { + if (key === "parent") continue; + if (Array.isArray(child)) { + for (const item of child) { + collectVarBindingNames(item, names); + } + } else if (isRecord(child)) { + collectVarBindingNames(child, names); + } + } +} + +function collectFunctionScopeBindingNames(node: AstRecord): Set { + const names = new Set(); + + if (getString(node, "type") === "FunctionExpression") { + const name = nodeName(node.id); + if (name) names.add(name); + } + + for (const param of getArray(node, "params")) { + addBindingName(param, names); + } + + collectVarBindingNames(node.body, names); + return names; +} + +function collectForBindingNames(node: AstRecord): Set { + const names = new Set(); + addVariableDeclarationBindingNames(node.init, names); + addVariableDeclarationBindingNames(node.left, names); + return names; +} + +function withoutBindings(activeNames: Set, localNames: Set): Set { + if (activeNames.size === 0 || localNames.size === 0) return activeNames; + + let scoped: Set | null = null; + for (const name of localNames) { + if (!activeNames.has(name)) continue; + scoped ??= new Set(activeNames); + scoped.delete(name); + } + + return scoped ?? activeNames; +} + +function visitChildren( + node: AstRecord, + dynamicLocals: Set, + visitor: (node: AstRecord) => void, +): void { + for (const [key, child] of Object.entries(node)) { + if (key === "parent") continue; + if (Array.isArray(child)) { + for (const item of child) { + visitDynamicCalls(item, dynamicLocals, visitor); + } + } else if (isRecord(child)) { + visitDynamicCalls(child, dynamicLocals, visitor); + } + } +} + +function visitDynamicCalls( + value: unknown, + dynamicLocals: Set, + visitor: (node: AstRecord) => void, +): void { + if (!isRecord(value) || dynamicLocals.size === 0) return; + + const type = getString(value, "type"); + if (type === "Program") { + const scoped = withoutBindings( + dynamicLocals, + collectBlockScopedBindingNames(getArray(value, "body")), + ); + for (const statement of getArray(value, "body")) { + visitDynamicCalls(statement, scoped, visitor); + } + return; + } + + if (type === "BlockStatement") { + const scoped = withoutBindings( + dynamicLocals, + collectBlockScopedBindingNames(getArray(value, "body")), + ); + for (const statement of getArray(value, "body")) { + visitDynamicCalls(statement, scoped, visitor); + } + return; + } + + if ( + type === "FunctionDeclaration" || + type === "FunctionExpression" || + type === "ArrowFunctionExpression" + ) { + visitChildren( + value, + withoutBindings(dynamicLocals, collectFunctionScopeBindingNames(value)), + visitor, + ); + return; + } + + if (type === "ForStatement" || type === "ForInStatement" || type === "ForOfStatement") { + visitChildren(value, withoutBindings(dynamicLocals, collectForBindingNames(value)), visitor); + return; + } + + if (type === "CatchClause") { + const names = new Set(); + addBindingName(value.param, names); + visitChildren(value, withoutBindings(dynamicLocals, names), visitor); + return; + } + + if (isDynamicCall(value, dynamicLocals)) { + visitor(value); + } + visitChildren(value, dynamicLocals, visitor); +} + function collectImportSpecifiers(node: unknown): string[] { const specifiers: string[] = []; const seen = new Set(); @@ -128,6 +342,7 @@ function collectImportSpecifiers(node: unknown): string[] { function propertyKeyName(property: unknown): string | null { if (!isRecord(property)) return null; + if (getBoolean(property, "computed")) return null; return nodeName(property.key); } @@ -140,6 +355,17 @@ function hasObjectProperty(node: unknown, name: string): boolean { return objectProperties(node).some((property) => propertyKeyName(property) === name); } +function findObjectProperty(node: unknown, name: string): AstRecord | null { + return objectProperties(node).find((property) => propertyKeyName(property) === name) ?? null; +} + +function dynamicLoaderNode(firstArg: unknown): unknown { + if (!isRecord(firstArg) || getString(firstArg, "type") !== "ObjectExpression") return firstArg; + const loaderProperty = + findObjectProperty(firstArg, "loader") ?? findObjectProperty(firstArg, "modules"); + return loaderProperty?.value; +} + function findLastEndedProperty(node: AstRecord): AstRecord | null { const properties = objectProperties(node); for (let index = properties.length - 1; index >= 0; index -= 1) { @@ -290,10 +516,9 @@ export async function transformNextDynamicPreloadMetadata( let changed = false; const pending: Promise[] = []; - walkAst(ast, (node) => { - if (!isDynamicCall(node, dynamicLocals)) return; + visitDynamicCalls(ast, dynamicLocals, (node) => { const args = getArray(node, "arguments"); - const specifiers = collectImportSpecifiers(args[0]); + const specifiers = collectImportSpecifiers(dynamicLoaderNode(args[0])); if (specifiers.length === 0) return; pending.push( diff --git a/tests/build-optimization.test.ts b/tests/build-optimization.test.ts index 9ce99aa89..0adb629b4 100644 --- a/tests/build-optimization.test.ts +++ b/tests/build-optimization.test.ts @@ -1263,7 +1263,9 @@ describe("next/dynamic preload metadata transform", () => { ? path.join(root, "app/dynamic-widget.tsx") : specifier === "./named" ? path.join(root, "app/named.tsx") - : null; + : specifier === "./ignored" + ? path.join(root, "app/ignored.tsx") + : null; it("adds loadableGenerated modules to dynamic loader calls", async () => { const result = await _transformNextDynamicPreloadMetadata( @@ -1307,6 +1309,88 @@ describe("next/dynamic preload metadata transform", () => { expect(result?.code).toContain(`loadableGenerated: { modules: ["app/named.tsx"] }`); }); + + it("does not transform a function parameter that shadows the next/dynamic import", async () => { + const code = [ + `import dynamic from "next/dynamic";`, + `function makeThing(dynamic) {`, + ` return dynamic(() => import("./dynamic-widget"));`, + `}`, + ].join("\n"); + const result = await _transformNextDynamicPreloadMetadata( + code, + importer, + root, + resolveDynamicImport, + ); + + expect(result).toBeNull(); + }); + + it("does not transform an arrow parameter that shadows the next/dynamic import", async () => { + const code = [ + `import dynamic from "next/dynamic";`, + `const makeThing = (dynamic) => dynamic(() => import("./dynamic-widget"));`, + ].join("\n"); + const result = await _transformNextDynamicPreloadMetadata( + code, + importer, + root, + resolveDynamicImport, + ); + + expect(result).toBeNull(); + }); + + it("does not transform a block binding that shadows the next/dynamic import", async () => { + const code = [ + `import dynamic from "next/dynamic";`, + `{`, + ` const dynamic = customFactory;`, + ` dynamic(() => import("./dynamic-widget"));`, + `}`, + ].join("\n"); + const result = await _transformNextDynamicPreloadMetadata( + code, + importer, + root, + resolveDynamicImport, + ); + + expect(result).toBeNull(); + }); + + it("transforms renamed next/dynamic imports", async () => { + const result = await _transformNextDynamicPreloadMetadata( + [ + `import loadDynamic from "next/dynamic";`, + `const Widget = loadDynamic(() => import("./dynamic-widget"));`, + ].join("\n"), + importer, + root, + resolveDynamicImport, + ); + + expect(result?.code).toContain(`loadableGenerated: { modules: ["app/dynamic-widget.tsx"] }`); + }); + + it("only records the object-form loader import", async () => { + const result = await _transformNextDynamicPreloadMetadata( + [ + `import dynamic from "next/dynamic";`, + `const Widget = dynamic({`, + ` loader: () => import("./dynamic-widget"),`, + ` debugOnly: () => import("./ignored"),`, + `});`, + ].join("\n"), + importer, + root, + resolveDynamicImport, + ); + + expect(result?.code).toContain(`loadableGenerated: { modules: ["app/dynamic-widget.tsx"] }`); + expect(result?.code).not.toContain("app/ignored.tsx"); + }); }); describe("augmentSsrManifestFromBundle", () => { From 3c7db502850c2549a78108bb7e2723e3f5e76c6d Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Tue, 26 May 2026 16:40:47 +1000 Subject: [PATCH 07/14] fix: model switch and class scopes in dynamic preload transform The dynamic preload metadata transform still treated imported next/dynamic names as active inside switch case lexical scopes and named class expression bodies. That could inject loadableGenerated metadata into calls that resolve to local bindings rather than the import. The scope walker now removes switch case-block bindings while traversing case tests and consequents, leaves the switch discriminant in the outer scope, and scopes named class declarations and expressions through their children. Regression tests cover switch case shadowing and named class expressions. --- .../src/plugins/dynamic-preload-metadata.ts | 33 +++++++++++++++++ tests/build-optimization.test.ts | 36 +++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/packages/vinext/src/plugins/dynamic-preload-metadata.ts b/packages/vinext/src/plugins/dynamic-preload-metadata.ts index 6f9d90b68..3cec54bb2 100644 --- a/packages/vinext/src/plugins/dynamic-preload-metadata.ts +++ b/packages/vinext/src/plugins/dynamic-preload-metadata.ts @@ -170,6 +170,21 @@ function collectBlockScopedBindingNames(body: readonly unknown[]): Set { return names; } +function collectSwitchScopedBindingNames(node: AstRecord): Set { + const names = new Set(); + + for (const switchCase of getArray(node, "cases")) { + if (!isRecord(switchCase)) continue; + for (const statement of getArray(switchCase, "consequent")) { + for (const name of collectBlockScopedBindingNames([statement])) { + names.add(name); + } + } + } + + return names; +} + function collectVarBindingNames(value: unknown, names: Set): void { if (!isRecord(value)) return; @@ -281,6 +296,16 @@ function visitDynamicCalls( return; } + if (type === "SwitchStatement") { + visitDynamicCalls(value.discriminant, dynamicLocals, visitor); + + const scoped = withoutBindings(dynamicLocals, collectSwitchScopedBindingNames(value)); + for (const switchCase of getArray(value, "cases")) { + visitDynamicCalls(switchCase, scoped, visitor); + } + return; + } + if ( type === "FunctionDeclaration" || type === "FunctionExpression" || @@ -294,6 +319,14 @@ function visitDynamicCalls( return; } + if (type === "ClassDeclaration" || type === "ClassExpression") { + const names = new Set(); + const name = nodeName(value.id); + if (name) names.add(name); + visitChildren(value, withoutBindings(dynamicLocals, names), visitor); + return; + } + if (type === "ForStatement" || type === "ForInStatement" || type === "ForOfStatement") { visitChildren(value, withoutBindings(dynamicLocals, collectForBindingNames(value)), visitor); return; diff --git a/tests/build-optimization.test.ts b/tests/build-optimization.test.ts index 0adb629b4..08cbfd227 100644 --- a/tests/build-optimization.test.ts +++ b/tests/build-optimization.test.ts @@ -1360,6 +1360,42 @@ describe("next/dynamic preload metadata transform", () => { expect(result).toBeNull(); }); + it("does not transform a switch case binding that shadows the next/dynamic import", async () => { + const code = [ + `import dynamic from "next/dynamic";`, + `switch (kind) {`, + ` case "x":`, + ` const dynamic = customFactory;`, + ` dynamic(() => import("./dynamic-widget"));`, + `}`, + ].join("\n"); + const result = await _transformNextDynamicPreloadMetadata( + code, + importer, + root, + resolveDynamicImport, + ); + + expect(result).toBeNull(); + }); + + it("does not transform inside a named class expression that shadows the next/dynamic import", async () => { + const code = [ + `import dynamic from "next/dynamic";`, + `const Component = class dynamic {`, + ` static Widget = dynamic(() => import("./dynamic-widget"));`, + `};`, + ].join("\n"); + const result = await _transformNextDynamicPreloadMetadata( + code, + importer, + root, + resolveDynamicImport, + ); + + expect(result).toBeNull(); + }); + it("transforms renamed next/dynamic imports", async () => { const result = await _transformNextDynamicPreloadMetadata( [ From 59a78aa0247e2f47abe0d60b9a7dca333249fc21 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 4 Jun 2026 18:14:42 +1000 Subject: [PATCH 08/14] fix: address PR review feedback for next/dynamic CSP nonce MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop `as="style"` from `` — `as` is a preload attribute per the HTML spec, semantically incorrect on stylesheet links. Browsers ignore it but it produces bad HTML. - Remove brittle `import(` string gate from the transform — it could miss `import ("./x")` (whitespace between import and paren). The Vite filter already gates on "next/dynamic" and the AST parse is the real validation. - Add comment explaining MagicString mutation safety in Promise.all. - Add comment explaining AST end-exclusive assumption on closeParen. - Add test for whitespace-separated dynamic import syntax. --- .../src/plugins/dynamic-preload-metadata.ts | 6 ++++-- packages/vinext/src/shims/dynamic.ts | 1 - tests/build-optimization.test.ts | 17 +++++++++++++++++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/vinext/src/plugins/dynamic-preload-metadata.ts b/packages/vinext/src/plugins/dynamic-preload-metadata.ts index 3cec54bb2..db53698ee 100644 --- a/packages/vinext/src/plugins/dynamic-preload-metadata.ts +++ b/packages/vinext/src/plugins/dynamic-preload-metadata.ts @@ -441,7 +441,7 @@ function insertSecondOptionsArgument( const firstArgEnd = getNumber(firstArg, "end"); if (callEnd === null || firstArgEnd === null) return false; - const closeParen = callEnd - 1; + const closeParen = callEnd - 1; // AST `end` is exclusive (past the closing paren) const betweenFirstArgAndClose = code.slice(firstArgEnd, closeParen); if (betweenFirstArgAndClose.includes(",")) { output.overwrite(firstArgEnd, closeParen, `, ${optionsLiteral}`); @@ -533,7 +533,7 @@ export async function transformNextDynamicPreloadMetadata( root: string, resolveDynamicImport: ResolveDynamicImport, ): Promise { - if (!code.includes("next/dynamic") || !code.includes("import(")) return null; + if (!code.includes("next/dynamic")) return null; let ast: unknown; try { @@ -549,6 +549,8 @@ export async function transformNextDynamicPreloadMetadata( let changed = false; const pending: Promise[] = []; + // Mutations are safe: microtask callbacks from Promise.all run sequentially + // on the JS microtask queue, so no two .then() callbacks overlap. visitDynamicCalls(ast, dynamicLocals, (node) => { const args = getArray(node, "arguments"); const specifiers = collectImportSpecifiers(dynamicLoaderNode(args[0])); diff --git a/packages/vinext/src/shims/dynamic.ts b/packages/vinext/src/shims/dynamic.ts index 42ddb039a..412e56d52 100644 --- a/packages/vinext/src/shims/dynamic.ts +++ b/packages/vinext/src/shims/dynamic.ts @@ -146,7 +146,6 @@ function DynamicPreloadChunks(props: { moduleIds?: readonly string[] }) { React.createElement("link", { key: href, rel: "stylesheet", - as: "style", href, nonce, precedence: "dynamic", diff --git a/tests/build-optimization.test.ts b/tests/build-optimization.test.ts index 613602eca..592b7f90e 100644 --- a/tests/build-optimization.test.ts +++ b/tests/build-optimization.test.ts @@ -1499,6 +1499,23 @@ describe("next/dynamic preload metadata transform", () => { expect(result?.code).toContain(`loadableGenerated: { modules: ["app/dynamic-widget.tsx"] }`); expect(result?.code).not.toContain("app/ignored.tsx"); }); + + it("transforms dynamic imports with whitespace between import and paren", async () => { + const result = await _transformNextDynamicPreloadMetadata( + [ + `import dynamic from "next/dynamic";`, + `const Widget = dynamic(() => import ("./dynamic_widget"), { loading: Loading });`, + ].join("\n"), + importer, + root, + async (specifier) => + specifier === "./dynamic_widget" + ? path.join(root, "app/dynamic_widget.tsx") + : null, + ); + + expect(result?.code).toContain(`loadableGenerated: { modules: ["app/dynamic_widget.tsx"] }`); + }); }); describe("augmentSsrManifestFromBundle", () => { From 4012349382f2ae90733fcc267c5221854751f198 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 4 Jun 2026 18:26:47 +1000 Subject: [PATCH 09/14] fix: update global.d.ts comment to mention asset prefix, fix formatting --- packages/vinext/src/global.d.ts | 2 +- tests/build-optimization.test.ts | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/vinext/src/global.d.ts b/packages/vinext/src/global.d.ts index eac0a8cd3..c8c69fea8 100644 --- a/packages/vinext/src/global.d.ts +++ b/packages/vinext/src/global.d.ts @@ -248,7 +248,7 @@ declare global { * Per-module preload files for rendered `next/dynamic()` boundaries. * Keys are root-relative module IDs injected by vinext's dynamic metadata * transform. Values are JS/CSS files from Vite's build manifest, with any - * configured base path already applied. + * configured base path / asset prefix already applied. */ // oxlint-disable-next-line no-var var __VINEXT_DYNAMIC_PRELOADS__: Record | undefined; diff --git a/tests/build-optimization.test.ts b/tests/build-optimization.test.ts index 592b7f90e..839941c43 100644 --- a/tests/build-optimization.test.ts +++ b/tests/build-optimization.test.ts @@ -1509,9 +1509,7 @@ describe("next/dynamic preload metadata transform", () => { importer, root, async (specifier) => - specifier === "./dynamic_widget" - ? path.join(root, "app/dynamic_widget.tsx") - : null, + specifier === "./dynamic_widget" ? path.join(root, "app/dynamic_widget.tsx") : null, ); expect(result?.code).toContain(`loadableGenerated: { modules: ["app/dynamic_widget.tsx"] }`); From 34cd5c06dd9f9b6ad826168c3b3ae21a0cfebb04 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 4 Jun 2026 18:34:11 +1000 Subject: [PATCH 10/14] refactor: extract computeClientRuntimeMetadata shared helper Extract the duplicated client-manifest runtime metadata computation (lazy chunks, dynamic preloads, client entry) into a single shared helper in utils/client-runtime-metadata.ts. Both prod-server.ts (Pages Router Node.js production server) and index.ts (Cloudflare closeBundle hook) now call the same helper, eliminating the highest-risk seam in the PR where basePath/ assetPrefix handling produced bugs. --- packages/vinext/src/index.ts | 72 ++---- packages/vinext/src/server/prod-server.ts | 61 ++--- .../src/utils/client-runtime-metadata.ts | 70 ++++++ tests/client-build-manifest.test.ts | 226 ++++++++++++++++++ 4 files changed, 326 insertions(+), 103 deletions(-) create mode 100644 packages/vinext/src/utils/client-runtime-metadata.ts diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index c30a7623b..f805b14a8 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -87,7 +87,6 @@ import { import { scanMetadataFiles } from "./server/metadata-routes.js"; import { buildRequestHeadersFromMiddlewareResponse } from "./server/middleware-request-headers.js"; import { detectPackageManager } from "./utils/project.js"; -import { manifestFileWithAssetPrefix } from "./utils/manifest-paths.js"; import { hasBasePath } from "./utils/base-path.js"; import { mergeRewriteQuery } from "./utils/query.js"; import { @@ -124,12 +123,7 @@ import { createLocalFontsPlugin, } from "./plugins/fonts.js"; import { hasWranglerConfig, formatMissingCloudflarePluginError } from "./deploy.js"; -import { - computeDynamicImportPreloads, - computeLazyChunks, - dynamicImportPreloadsWithBase, -} from "./utils/lazy-chunks.js"; -import { findClientEntryFile, readClientBuildManifest } from "./utils/client-build-manifest.js"; +import { computeClientRuntimeMetadata } from "./utils/client-runtime-metadata.js"; import { resolvePostcssStringPlugins } from "./plugins/postcss.js"; import { buildSassPreprocessorOptions } from "./plugins/sass.js"; import { @@ -4455,36 +4449,21 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { const clientDir = path.resolve(buildRoot, "dist", "client"); const clientBase = envConfig.base ?? "/"; - // Read build manifest and compute lazy chunks (only reachable via - // dynamic imports), plus per-next/dynamic preload files. This runs - // for BOTH App Router and Pages Router. - // clientEntryFile is only used by the Pages Router path below — - // App Router gets its client entry via the RSC plugin instead. - let lazyChunksData: string[] | null = null; - let dynamicPreloadsData: Record | null = null; - let clientEntryFile: string | null = null; - const buildManifestPath = path.join(clientDir, ".vite", "manifest.json"); - const buildManifest = readClientBuildManifest(buildManifestPath); - if (buildManifest) { - clientEntryFile = - findClientEntryFile({ - buildManifest, - clientDir, - assetsSubdir: resolveAssetsDir(nextConfig?.assetPrefix), - assetBase: clientBase, - }) ?? null; - const lazy = computeLazyChunks(buildManifest).map((file) => - manifestFileWithAssetPrefix(file, clientBase, nextConfig.assetPrefix), - ); - if (lazy.length > 0) lazyChunksData = lazy; - const dynamicPreloads = dynamicImportPreloadsWithBase( - computeDynamicImportPreloads(buildManifest), - (file) => manifestFileWithAssetPrefix(file, clientBase, nextConfig.assetPrefix), - ); - if (Object.keys(dynamicPreloads).length > 0) { - dynamicPreloadsData = dynamicPreloads; - } - } + // Compute runtime metadata from the client build manifest: lazy + // chunks, per-next/dynamic preload files, and (for Pages Router) + // the client entry file. This runs for BOTH App Router and Pages + // Router — clientEntryFile is only used by the Pages Router path + // below (App Router gets its client entry via the RSC plugin). + const runtimeMetadata = computeClientRuntimeMetadata({ + clientDir, + assetBase: clientBase, + assetPrefix: nextConfig.assetPrefix, + includeClientEntry: !hasAppDir, + }); + const lazyChunksData: string[] | null = runtimeMetadata.lazyChunks ?? null; + const dynamicPreloadsData: Record | null = + runtimeMetadata.dynamicPreloads ?? null; + let clientEntryFile: string | null = runtimeMetadata.clientEntryFile ?? null; // Read SSR manifest for per-page CSS/JS injection let ssrManifestData: Record | null = null; @@ -4547,25 +4526,6 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { const workerEntry = path.join(workerOutDir, "index.js"); if (!fs.existsSync(workerEntry)) return; - // Fallback: scan the on-disk assets directory for the client entry - // chunk when the SSR manifest lookup didn't surface one. Pages Router - // uses "vinext-client-entry", App Router uses "vinext-app-browser-entry". - // - // When `assetPrefix` is configured, chunks live under - // `/_next/static/` (path-prefix) or `_next/static/` - // (absolute-URL prefix) — NOT `assets/`. Resolve the actual - // subdirectory from the same helper that drives `build.assetsDir` - // and the prod-server lookup path, so this fallback works for every - // layout supported by the rest of the pipeline. - if (!clientEntryFile) { - clientEntryFile = - findClientEntryFile({ - clientDir, - assetsSubdir: resolveAssetsDir(nextConfig?.assetPrefix), - assetBase: clientBase, - }) ?? null; - } - // Prepend globals to worker entry if (clientEntryFile || ssrManifestData || lazyChunksData || dynamicPreloadsData) { let code = fs.readFileSync(workerEntry, "utf-8"); diff --git a/packages/vinext/src/server/prod-server.ts b/packages/vinext/src/server/prod-server.ts index 9d53592b8..3b0d5c065 100644 --- a/packages/vinext/src/server/prod-server.ts +++ b/packages/vinext/src/server/prod-server.ts @@ -64,15 +64,8 @@ import { ASSET_PREFIX_URL_DIR, assetPrefixPathname, isAbsoluteAssetPrefix, - resolveAssetsDir, } from "../utils/asset-prefix.js"; -import { - computeDynamicImportPreloads, - computeLazyChunks, - dynamicImportPreloadsWithBase, -} from "../utils/lazy-chunks.js"; -import { manifestFileWithAssetPrefix } from "../utils/manifest-paths.js"; -import { findClientEntryFile, readClientBuildManifest } from "../utils/client-build-manifest.js"; +import { computeClientRuntimeMetadata } from "../utils/client-runtime-metadata.js"; import { normalizePathnameForRouteMatchStrict } from "../routing/utils.js"; import type { ExecutionContextLike } from "vinext/shims/request-context"; import { collectInlineCssManifest } from "../build/inline-css.js"; @@ -303,31 +296,9 @@ function installClientBuildManifestGlobals( assetBase: string, assetPrefix: string, ): void { - globalThis.__VINEXT_LAZY_CHUNKS__ = undefined; - globalThis.__VINEXT_DYNAMIC_PRELOADS__ = undefined; - - const buildManifestPath = path.join(clientDir, ".vite", "manifest.json"); - if (!fs.existsSync(buildManifestPath)) return; - - try { - const buildManifest = JSON.parse(fs.readFileSync(buildManifestPath, "utf-8")); - const lazyChunks = computeLazyChunks(buildManifest).map((file: string) => - manifestFileWithAssetPrefix(file, assetBase, assetPrefix), - ); - if (lazyChunks.length > 0) { - globalThis.__VINEXT_LAZY_CHUNKS__ = lazyChunks; - } - - const dynamicPreloads = dynamicImportPreloadsWithBase( - computeDynamicImportPreloads(buildManifest), - (file) => manifestFileWithAssetPrefix(file, assetBase, assetPrefix), - ); - if (Object.keys(dynamicPreloads).length > 0) { - globalThis.__VINEXT_DYNAMIC_PRELOADS__ = dynamicPreloads; - } - } catch { - /* ignore parse errors */ - } + const metadata = computeClientRuntimeMetadata({ clientDir, assetBase, assetPrefix }); + globalThis.__VINEXT_LAZY_CHUNKS__ = metadata.lazyChunks; + globalThis.__VINEXT_DYNAMIC_PRELOADS__ = metadata.dynamicPreloads; } function isNoBodyResponseStatus(status: number): boolean { @@ -1453,23 +1424,19 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) { ssrManifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8")); } - // Load the build manifest to expose the Pages Router client entry and compute - // lazy chunks + dynamic preload metadata. These globals are read by shims - // during SSR. Prerendered HTML is rendered through this Node server too, so - // it needs the same client-entry global that Cloudflare builds inject into - // the Worker entry at build time. - const buildManifestPath = path.join(clientDir, ".vite", "manifest.json"); - const buildManifest = readClientBuildManifest(buildManifestPath); - // findClientEntryFile handles a missing manifest by skipping the manifest - // lookup and going straight to the on-disk fallback, so the call is the same - // either way — only the lazy-chunk computation needs the manifest. - globalThis.__VINEXT_CLIENT_ENTRY__ = findClientEntryFile({ - buildManifest, + // Compute client runtime metadata from the build manifest: client entry, + // lazy chunks, and dynamic preload files. These globals are read by shims + // during SSR and must match what Cloudflare builds inject into the Worker + // entry at build time. + const runtimeMetadata = computeClientRuntimeMetadata({ clientDir, - assetsSubdir: resolveAssetsDir(assetPrefix), assetBase, + assetPrefix, + includeClientEntry: true, }); - installClientBuildManifestGlobals(clientDir, assetBase, assetPrefix); + globalThis.__VINEXT_CLIENT_ENTRY__ = runtimeMetadata.clientEntryFile; + globalThis.__VINEXT_LAZY_CHUNKS__ = runtimeMetadata.lazyChunks; + globalThis.__VINEXT_DYNAMIC_PRELOADS__ = runtimeMetadata.dynamicPreloads; // Build the static file metadata cache at startup (same as App Router). const staticCache = await StaticFileCache.create(clientDir); diff --git a/packages/vinext/src/utils/client-runtime-metadata.ts b/packages/vinext/src/utils/client-runtime-metadata.ts new file mode 100644 index 000000000..bf241840d --- /dev/null +++ b/packages/vinext/src/utils/client-runtime-metadata.ts @@ -0,0 +1,70 @@ +import path from "node:path"; +import { readClientBuildManifest, findClientEntryFile } from "./client-build-manifest.js"; +import { + computeLazyChunks, + computeDynamicImportPreloads, + dynamicImportPreloadsWithBase, +} from "./lazy-chunks.js"; +import { manifestFileWithAssetPrefix } from "./manifest-paths.js"; +import { resolveAssetsDir } from "./asset-prefix.js"; + +export type ClientRuntimeMetadata = { + clientEntryFile?: string; + lazyChunks?: string[]; + dynamicPreloads?: Record; +}; + +/** + * Read the client build manifest (`.vite/manifest.json`) and compute runtime + * metadata used by the Cloudflare worker entry (build time) and the Pages + * Router production server (startup time). + * + * - `lazyChunks` — chunks only reachable through dynamic `import()`, excluded + * from modulepreload hints. + * - `dynamicPreloads` — per-module JS/CSS files for rendered `next/dynamic()` + * boundaries, injected as preload links during SSR. + * - `clientEntryFile` — the client entry chunk filename (optional, only + * needed for Pages Router). + * + * All file paths are normalised with the configured `assetBase` (basePath) + * and `assetPrefix`. + */ +export function computeClientRuntimeMetadata(opts: { + clientDir: string; + assetBase: string; + assetPrefix: string; + includeClientEntry?: boolean; +}): ClientRuntimeMetadata { + const buildManifestPath = path.join(opts.clientDir, ".vite", "manifest.json"); + const buildManifest = readClientBuildManifest(buildManifestPath); + + const metadata: ClientRuntimeMetadata = {}; + + if (opts.includeClientEntry) { + const entry = findClientEntryFile({ + buildManifest, + clientDir: opts.clientDir, + assetsSubdir: resolveAssetsDir(opts.assetPrefix), + assetBase: opts.assetBase, + }); + if (entry) metadata.clientEntryFile = entry; + } + + if (!buildManifest) return metadata; + + const applyAssetPrefix = (file: string) => + manifestFileWithAssetPrefix(file, opts.assetBase, opts.assetPrefix); + + const lazyChunks = computeLazyChunks(buildManifest).map(applyAssetPrefix); + if (lazyChunks.length > 0) metadata.lazyChunks = lazyChunks; + + const dynamicPreloads = dynamicImportPreloadsWithBase( + computeDynamicImportPreloads(buildManifest), + applyAssetPrefix, + ); + if (Object.keys(dynamicPreloads).length > 0) { + metadata.dynamicPreloads = dynamicPreloads; + } + + return metadata; +} diff --git a/tests/client-build-manifest.test.ts b/tests/client-build-manifest.test.ts index 998525a8c..7714191a2 100644 --- a/tests/client-build-manifest.test.ts +++ b/tests/client-build-manifest.test.ts @@ -7,6 +7,7 @@ import { findClientEntryFileFromManifest, readClientBuildManifest, } from "../packages/vinext/src/utils/client-build-manifest.js"; +import { computeClientRuntimeMetadata } from "../packages/vinext/src/utils/client-runtime-metadata.js"; describe("client build manifest helpers", () => { let tmpDir: string; @@ -114,3 +115,228 @@ describe("client build manifest helpers", () => { expect(entry).toBe("cdn/_next/static/vinext-client-entry-5678.js"); }); }); + +describe("computeClientRuntimeMetadata", () => { + let tmpDir: string; + let clientDir: string; + + beforeEach(async () => { + tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-client-runtime-metadata-")); + clientDir = path.join(tmpDir, "client"); + await fsp.mkdir(path.join(clientDir, ".vite"), { recursive: true }); + }); + + afterEach(async () => { + await fsp.rm(tmpDir, { recursive: true, force: true }); + }); + + const baseManifest = { + "entry.js": { + file: "_next/static/entry-abc123.js", + isEntry: true, + imports: ["shared.js"], + dynamicImports: ["LazyComponent"], + }, + "shared.js": { + file: "_next/static/shared-def456.js", + }, + LazyComponent: { + file: "_next/static/lazy-ghi789.js", + isDynamicEntry: true, + css: ["_next/static/lazy-jkl012.css"], + }, + }; + + it("returns empty metadata when no manifest exists", () => { + const result = computeClientRuntimeMetadata({ + clientDir, + assetBase: "/", + assetPrefix: "", + }); + expect(result).toEqual({}); + }); + + it("returns empty metadata when manifest is malformed", async () => { + await fsp.writeFile(path.join(clientDir, ".vite", "manifest.json"), "not json"); + const result = computeClientRuntimeMetadata({ + clientDir, + assetBase: "/", + assetPrefix: "", + }); + expect(result).toEqual({}); + }); + + it("computes client entry, lazy chunks, and dynamic preloads without base path or asset prefix", async () => { + await fsp.writeFile( + path.join(clientDir, ".vite", "manifest.json"), + JSON.stringify(baseManifest), + ); + const result = computeClientRuntimeMetadata({ + clientDir, + assetBase: "/", + assetPrefix: "", + includeClientEntry: true, + }); + expect(result).toEqual({ + clientEntryFile: "_next/static/entry-abc123.js", + lazyChunks: ["_next/static/lazy-ghi789.js"], + dynamicPreloads: { + LazyComponent: ["_next/static/lazy-ghi789.js", "_next/static/lazy-jkl012.css"], + }, + }); + }); + + it("applies base path to all paths", async () => { + await fsp.writeFile( + path.join(clientDir, ".vite", "manifest.json"), + JSON.stringify(baseManifest), + ); + const result = computeClientRuntimeMetadata({ + clientDir, + assetBase: "/docs/", + assetPrefix: "", + includeClientEntry: true, + }); + expect(result).toEqual({ + clientEntryFile: "docs/_next/static/entry-abc123.js", + lazyChunks: ["docs/_next/static/lazy-ghi789.js"], + dynamicPreloads: { + LazyComponent: ["docs/_next/static/lazy-ghi789.js", "docs/_next/static/lazy-jkl012.css"], + }, + }); + }); + + it("applies path-based asset prefix", async () => { + await fsp.writeFile( + path.join(clientDir, ".vite", "manifest.json"), + JSON.stringify(baseManifest), + ); + const result = computeClientRuntimeMetadata({ + clientDir, + assetBase: "/", + assetPrefix: "/cdn", + includeClientEntry: true, + }); + expect(result).toEqual({ + clientEntryFile: "_next/static/entry-abc123.js", + lazyChunks: ["cdn/_next/static/lazy-ghi789.js"], + dynamicPreloads: { + LazyComponent: ["cdn/_next/static/lazy-ghi789.js", "cdn/_next/static/lazy-jkl012.css"], + }, + }); + }); + + it("applies absolute URL asset prefix", async () => { + await fsp.writeFile( + path.join(clientDir, ".vite", "manifest.json"), + JSON.stringify(baseManifest), + ); + const result = computeClientRuntimeMetadata({ + clientDir, + assetBase: "/", + assetPrefix: "https://cdn.example.com/assets", + includeClientEntry: true, + }); + expect(result).toEqual({ + clientEntryFile: "_next/static/entry-abc123.js", + lazyChunks: ["https://cdn.example.com/assets/_next/static/lazy-ghi789.js"], + dynamicPreloads: { + LazyComponent: [ + "https://cdn.example.com/assets/_next/static/lazy-ghi789.js", + "https://cdn.example.com/assets/_next/static/lazy-jkl012.css", + ], + }, + }); + }); + + it("does not double-prefix when manifest already has asset-prefix paths", async () => { + await fsp.writeFile( + path.join(clientDir, ".vite", "manifest.json"), + JSON.stringify({ + "entry.js": { + file: "cdn/_next/static/entry-abc123.js", + isEntry: true, + imports: ["shared.js"], + dynamicImports: ["LazyComponent"], + }, + "shared.js": { + file: "cdn/_next/static/shared-def456.js", + }, + LazyComponent: { + file: "cdn/_next/static/lazy-ghi789.js", + isDynamicEntry: true, + css: ["cdn/_next/static/lazy-jkl012.css"], + }, + }), + ); + const result = computeClientRuntimeMetadata({ + clientDir, + assetBase: "/", + assetPrefix: "/cdn", + includeClientEntry: true, + }); + expect(result).toEqual({ + clientEntryFile: "cdn/_next/static/entry-abc123.js", + lazyChunks: ["cdn/_next/static/lazy-ghi789.js"], + dynamicPreloads: { + LazyComponent: ["cdn/_next/static/lazy-ghi789.js", "cdn/_next/static/lazy-jkl012.css"], + }, + }); + }); + + it("omits client entry when includeClientEntry is false", async () => { + await fsp.writeFile( + path.join(clientDir, ".vite", "manifest.json"), + JSON.stringify(baseManifest), + ); + const result = computeClientRuntimeMetadata({ + clientDir, + assetBase: "/", + assetPrefix: "", + includeClientEntry: false, + }); + expect(result.clientEntryFile).toBeUndefined(); + expect(result.lazyChunks).toBeDefined(); + expect(result.dynamicPreloads).toBeDefined(); + }); + + it("falls back to on-disk client entry scan when manifest has no entry", async () => { + await fsp.mkdir(path.join(clientDir, "_next", "static"), { recursive: true }); + await fsp.writeFile(path.join(clientDir, "_next", "static", "vinext-client-entry-abcd.js"), ""); + await fsp.writeFile( + path.join(clientDir, ".vite", "manifest.json"), + JSON.stringify({ + "lazy-chunk.js": { + file: "_next/static/lazy-1234.js", + isDynamicEntry: true, + }, + }), + ); + const result = computeClientRuntimeMetadata({ + clientDir, + assetBase: "/", + assetPrefix: "", + includeClientEntry: true, + }); + expect(result.clientEntryFile).toBe("_next/static/vinext-client-entry-abcd.js"); + expect(result.lazyChunks).toBeDefined(); + }); + + it("lazy chunks and dynamic preloads use the same URL normalisation path", async () => { + await fsp.writeFile( + path.join(clientDir, ".vite", "manifest.json"), + JSON.stringify(baseManifest), + ); + const result = computeClientRuntimeMetadata({ + clientDir, + assetBase: "/docs/", + assetPrefix: "/cdn", + includeClientEntry: true, + }); + // Both lazyChunks and dynamicPreloads pass through the same + // manifestFileWithAssetPrefix, so the same chunk file gets the + // identical URL regardless of which computation surface produced it. + expect(result.lazyChunks).toContain("cdn/_next/static/lazy-ghi789.js"); + expect(result.dynamicPreloads!.LazyComponent).toContain("cdn/_next/static/lazy-ghi789.js"); + }); +}); From 99655ef74657984a64dc0a7d0575b8e3bd4a9c59 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 4 Jun 2026 18:36:51 +1000 Subject: [PATCH 11/14] test: add TSX-generic and object-form metadata regression tests - Add post-type-strip generic call test verifying multi-line dynamic() calls work after TS/JSX stripping by esbuild. - Add object-form existing loadableGenerated preservation test to guard against duplicate injection in the object overload. --- tests/build-optimization.test.ts | 41 ++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/build-optimization.test.ts b/tests/build-optimization.test.ts index 839941c43..6d785167b 100644 --- a/tests/build-optimization.test.ts +++ b/tests/build-optimization.test.ts @@ -1514,6 +1514,47 @@ describe("next/dynamic preload metadata transform", () => { expect(result?.code).toContain(`loadableGenerated: { modules: ["app/dynamic_widget.tsx"] }`); }); + + it("transforms generic next/dynamic calls in TSX-shaped source after type stripping", async () => { + // Vite's built-in TS transform strips type annotations and JSX before + // the transform hook runs, so dynamic(args) becomes dynamic(args) + // and { loading: () =>

} becomes { loading: () => ... }. + // This test verifies the post-strip JS passes through parseAst correctly. + const result = await _transformNextDynamicPreloadMetadata( + [ + `import dynamic from "next/dynamic";`, + `const Widget = dynamic(`, + ` () => import("./dynamic-widget"),`, + ` { loading: () => null },`, + `);`, + ].join("\n"), + importer, + root, + async (specifier) => + specifier === "./dynamic-widget" ? path.join(root, "app/dynamic-widget.tsx") : null, + ); + + expect(result?.code).toContain(`loadableGenerated: { modules: ["app/dynamic-widget.tsx"] }`); + }); + + it("preserves existing loadableGenerated metadata in object-form dynamic options", async () => { + const code = [ + `import dynamic from "next/dynamic";`, + `const Widget = dynamic({`, + ` loader: () => import("./dynamic-widget"),`, + ` loadableGenerated: { modules: ["custom"] },`, + `});`, + ].join("\n"); + + const result = await _transformNextDynamicPreloadMetadata( + code, + importer, + root, + resolveDynamicImport, + ); + + expect(result).toBeNull(); + }); }); describe("augmentSsrManifestFromBundle", () => { From bbc758e224501dce2e29527416ef16a258cb8f45 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 4 Jun 2026 18:38:14 +1000 Subject: [PATCH 12/14] chore: remove unused ClientRuntimeMetadata type export --- packages/vinext/src/utils/client-runtime-metadata.ts | 2 +- pnpm-lock.yaml | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/vinext/src/utils/client-runtime-metadata.ts b/packages/vinext/src/utils/client-runtime-metadata.ts index bf241840d..924725a35 100644 --- a/packages/vinext/src/utils/client-runtime-metadata.ts +++ b/packages/vinext/src/utils/client-runtime-metadata.ts @@ -8,7 +8,7 @@ import { import { manifestFileWithAssetPrefix } from "./manifest-paths.js"; import { resolveAssetsDir } from "./asset-prefix.js"; -export type ClientRuntimeMetadata = { +type ClientRuntimeMetadata = { clientEntryFile?: string; lazyChunks?: string[]; dynamicPreloads?: Record; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed29918ad..51e941990 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1067,6 +1067,10 @@ importers: specifier: 'catalog:' version: 0.1.22(@opentelemetry/api@1.9.1)(@types/node@25.2.3)(@vitest/coverage-istanbul@4.1.6)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.8.3))(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.8.3) + tests/fixtures/css-url-assets-app: {} + + tests/fixtures/css-url-assets-pages: {} + tests/fixtures/ecosystem/better-auth: dependencies: '@opentelemetry/api': From 538bcdc3645fce30208ada2e92c918e32077411b Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 4 Jun 2026 18:38:34 +1000 Subject: [PATCH 13/14] revert: discard unrelated lockfile change from previous commit --- pnpm-lock.yaml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 51e941990..ed29918ad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1067,10 +1067,6 @@ importers: specifier: 'catalog:' version: 0.1.22(@opentelemetry/api@1.9.1)(@types/node@25.2.3)(@vitest/coverage-istanbul@4.1.6)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.2.3)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.8.3))(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.8.3) - tests/fixtures/css-url-assets-app: {} - - tests/fixtures/css-url-assets-pages: {} - tests/fixtures/ecosystem/better-auth: dependencies: '@opentelemetry/api': From 885d5e0c8832a193295c461b3aa54b3fc80236cc Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 4 Jun 2026 18:44:19 +1000 Subject: [PATCH 14/14] test: replace deploy test simulators with computeClientRuntimeMetadata simulateCloseBundleAppRouter and simulateCloseBundlePagesRouter no longer hand-roll lazy chunk and dynamic preload computation. They call the shared computeClientRuntimeMetadata helper directly, matching production wiring. Also: - Update helper doc comment to mention Node server startup - Remove unused imports (manifestFileWithAssetPrefix, manifestFileWithBase, computeDynamicImportPreloads, dynamicImportPreloadsWithBase) from deploy.test.ts --- .../src/utils/client-runtime-metadata.ts | 5 +- tests/deploy.test.ts | 120 +++++++----------- 2 files changed, 46 insertions(+), 79 deletions(-) diff --git a/packages/vinext/src/utils/client-runtime-metadata.ts b/packages/vinext/src/utils/client-runtime-metadata.ts index 924725a35..f82e9f7fe 100644 --- a/packages/vinext/src/utils/client-runtime-metadata.ts +++ b/packages/vinext/src/utils/client-runtime-metadata.ts @@ -15,9 +15,8 @@ type ClientRuntimeMetadata = { }; /** - * Read the client build manifest (`.vite/manifest.json`) and compute runtime - * metadata used by the Cloudflare worker entry (build time) and the Pages - * Router production server (startup time). + * Read the client build manifest and compute runtime metadata used by + * Cloudflare worker entry injection and Node production server startup. * * - `lazyChunks` — chunks only reachable through dynamic `import()`, excluded * from modulepreload hints. diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index 8865e51ef..27686d2d6 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -27,16 +27,9 @@ import { findInNodeModules, ensureViteConfigCompatibility, } from "../packages/vinext/src/utils/project.js"; -import { - manifestFileWithAssetPrefix, - manifestFileWithBase, -} from "../packages/vinext/src/utils/manifest-paths.js"; import { scanPublicFileRoutes } from "../packages/vinext/src/utils/public-routes.js"; -import { - computeDynamicImportPreloads, - computeLazyChunks, - dynamicImportPreloadsWithBase, -} from "../packages/vinext/src/utils/lazy-chunks.js"; +import { computeLazyChunks } from "../packages/vinext/src/utils/lazy-chunks.js"; +import { computeClientRuntimeMetadata } from "../packages/vinext/src/utils/client-runtime-metadata.js"; import { mergeHeaders, resolveStaticAssetSignal, @@ -1959,9 +1952,8 @@ describe("Cloudflare _headers file generation", () => { describe("Cloudflare closeBundle lazy chunk injection", () => { /** * Replicates the closeBundle hook logic for App Router builds. - * In #358's architecture, the RSC env IS the worker, so the worker entry - * is at dist/server/index.js. The RSC plugin handles __VINEXT_CLIENT_ENTRY__, - * but we still need to inject __VINEXT_LAZY_CHUNKS__ and __VINEXT_SSR_MANIFEST__. + * Uses the real computeClientRuntimeMetadata helper instead of + * duplicating manifest/runtime-metadata logic. */ function simulateCloseBundleAppRouter(buildRoot: string, base = "/", assetPrefix = ""): void { const distDir = path.resolve(buildRoot, "dist"); @@ -1969,26 +1961,11 @@ describe("Cloudflare closeBundle lazy chunk injection", () => { const clientDir = path.resolve(buildRoot, "dist", "client"); - // Read build manifest and compute dynamic chunk metadata - let lazyChunksData: string[] | null = null; - let dynamicPreloadsData: Record | null = null; - const buildManifestPath = path.join(clientDir, ".vite", "manifest.json"); - if (fs.existsSync(buildManifestPath)) { - try { - const buildManifest = JSON.parse(fs.readFileSync(buildManifestPath, "utf-8")); - const lazy = computeLazyChunks(buildManifest).map((file) => - manifestFileWithAssetPrefix(file, base, assetPrefix), - ); - if (lazy.length > 0) lazyChunksData = lazy; - const dynamicPreloads = dynamicImportPreloadsWithBase( - computeDynamicImportPreloads(buildManifest), - (file) => manifestFileWithAssetPrefix(file, base, assetPrefix), - ); - if (Object.keys(dynamicPreloads).length > 0) dynamicPreloadsData = dynamicPreloads; - } catch { - /* ignore */ - } - } + const runtimeMetadata = computeClientRuntimeMetadata({ + clientDir, + assetBase: base, + assetPrefix, + }); // Read SSR manifest let ssrManifestData: Record | null = null; @@ -2001,20 +1978,24 @@ describe("Cloudflare closeBundle lazy chunk injection", () => { } } - // App Router: inject into dist/server/index.js (NOT __VINEXT_CLIENT_ENTRY__) const workerEntry = path.resolve(distDir, "server", "index.js"); - if (fs.existsSync(workerEntry) && (lazyChunksData || dynamicPreloadsData || ssrManifestData)) { + if ( + fs.existsSync(workerEntry) && + (runtimeMetadata.lazyChunks || runtimeMetadata.dynamicPreloads || ssrManifestData) + ) { let code = fs.readFileSync(workerEntry, "utf-8"); const globals: string[] = []; if (ssrManifestData) { globals.push(`globalThis.__VINEXT_SSR_MANIFEST__ = ${JSON.stringify(ssrManifestData)};`); } - if (lazyChunksData) { - globals.push(`globalThis.__VINEXT_LAZY_CHUNKS__ = ${JSON.stringify(lazyChunksData)};`); + if (runtimeMetadata.lazyChunks) { + globals.push( + `globalThis.__VINEXT_LAZY_CHUNKS__ = ${JSON.stringify(runtimeMetadata.lazyChunks)};`, + ); } - if (dynamicPreloadsData) { + if (runtimeMetadata.dynamicPreloads) { globals.push( - `globalThis.__VINEXT_DYNAMIC_PRELOADS__ = ${JSON.stringify(dynamicPreloadsData)};`, + `globalThis.__VINEXT_DYNAMIC_PRELOADS__ = ${JSON.stringify(runtimeMetadata.dynamicPreloads)};`, ); } code = globals.join("\n") + "\n" + code; @@ -2024,8 +2005,8 @@ describe("Cloudflare closeBundle lazy chunk injection", () => { /** * Replicates the closeBundle hook logic for Pages Router builds. - * The worker entry is found by scanning dist/ for a directory containing - * wrangler.json. All three globals are injected. + * Uses the real computeClientRuntimeMetadata helper instead of + * duplicating manifest/runtime-metadata logic. */ function simulateCloseBundlePagesRouter(buildRoot: string, base = "/", assetPrefix = ""): void { const distDir = path.resolve(buildRoot, "dist"); @@ -2033,6 +2014,13 @@ describe("Cloudflare closeBundle lazy chunk injection", () => { const clientDir = path.resolve(buildRoot, "dist", "client"); + const runtimeMetadata = computeClientRuntimeMetadata({ + clientDir, + assetBase: base, + assetPrefix, + includeClientEntry: true, + }); + // Find worker output directory (contains wrangler.json) let workerOutDir: string | null = null; for (const entry of fs.readdirSync(distDir)) { @@ -2051,34 +2039,6 @@ describe("Cloudflare closeBundle lazy chunk injection", () => { const workerEntry = path.join(workerOutDir, "index.js"); if (!fs.existsSync(workerEntry)) return; - // Read build manifest and compute dynamic chunk metadata - let lazyChunksData: string[] | null = null; - let dynamicPreloadsData: Record | null = null; - let clientEntryFile: string | null = null; - const buildManifestPath = path.join(clientDir, ".vite", "manifest.json"); - if (fs.existsSync(buildManifestPath)) { - try { - const buildManifest = JSON.parse(fs.readFileSync(buildManifestPath, "utf-8")); - for (const [, value] of Object.entries(buildManifest) as [string, any][]) { - if (value && value.isEntry && value.file) { - clientEntryFile = manifestFileWithBase(value.file, base); - break; - } - } - const lazy = computeLazyChunks(buildManifest).map((file) => - manifestFileWithAssetPrefix(file, base, assetPrefix), - ); - if (lazy.length > 0) lazyChunksData = lazy; - const dynamicPreloads = dynamicImportPreloadsWithBase( - computeDynamicImportPreloads(buildManifest), - (file) => manifestFileWithAssetPrefix(file, base, assetPrefix), - ); - if (Object.keys(dynamicPreloads).length > 0) dynamicPreloadsData = dynamicPreloads; - } catch { - /* ignore */ - } - } - // Read SSR manifest let ssrManifestData: Record | null = null; const ssrManifestPath = path.join(clientDir, ".vite", "ssr-manifest.json"); @@ -2090,22 +2050,30 @@ describe("Cloudflare closeBundle lazy chunk injection", () => { } } - // Pages Router: inject all three globals - if (clientEntryFile || ssrManifestData || lazyChunksData || dynamicPreloadsData) { + if ( + runtimeMetadata.clientEntryFile || + ssrManifestData || + runtimeMetadata.lazyChunks || + runtimeMetadata.dynamicPreloads + ) { let code = fs.readFileSync(workerEntry, "utf-8"); const globals: string[] = []; - if (clientEntryFile) { - globals.push(`globalThis.__VINEXT_CLIENT_ENTRY__ = ${JSON.stringify(clientEntryFile)};`); + if (runtimeMetadata.clientEntryFile) { + globals.push( + `globalThis.__VINEXT_CLIENT_ENTRY__ = ${JSON.stringify(runtimeMetadata.clientEntryFile)};`, + ); } if (ssrManifestData) { globals.push(`globalThis.__VINEXT_SSR_MANIFEST__ = ${JSON.stringify(ssrManifestData)};`); } - if (lazyChunksData) { - globals.push(`globalThis.__VINEXT_LAZY_CHUNKS__ = ${JSON.stringify(lazyChunksData)};`); + if (runtimeMetadata.lazyChunks) { + globals.push( + `globalThis.__VINEXT_LAZY_CHUNKS__ = ${JSON.stringify(runtimeMetadata.lazyChunks)};`, + ); } - if (dynamicPreloadsData) { + if (runtimeMetadata.dynamicPreloads) { globals.push( - `globalThis.__VINEXT_DYNAMIC_PRELOADS__ = ${JSON.stringify(dynamicPreloadsData)};`, + `globalThis.__VINEXT_DYNAMIC_PRELOADS__ = ${JSON.stringify(runtimeMetadata.dynamicPreloads)};`, ); } code = globals.join("\n") + "\n" + code;