Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
15122a8
fix(app-router): preload next/dynamic chunks with CSP nonce
NathanDrake2406 May 25, 2026
f3dca36
fix: honor assetPrefix for dynamic preload globals
NathanDrake2406 May 25, 2026
df7e449
test: cover asset-prefixed dynamic preload nonces
NathanDrake2406 May 26, 2026
8c2a1b7
test: isolate asset-prefixed prod server globals
NathanDrake2406 May 26, 2026
1b561f1
refactor: reuse shared record guard in dynamic preload metadata
NathanDrake2406 May 26, 2026
4434405
fix: make dynamic preload metadata binding-aware
NathanDrake2406 May 26, 2026
3c7db50
fix: model switch and class scopes in dynamic preload transform
NathanDrake2406 May 26, 2026
6452f6f
Merge remote-tracking branch 'upstream/main' into nathan/next-dynamic…
NathanDrake2406 Jun 4, 2026
59a78aa
fix: address PR review feedback for next/dynamic CSP nonce
NathanDrake2406 Jun 4, 2026
4012349
fix: update global.d.ts comment to mention asset prefix, fix formatting
NathanDrake2406 Jun 4, 2026
34cd5c0
refactor: extract computeClientRuntimeMetadata shared helper
NathanDrake2406 Jun 4, 2026
99655ef
test: add TSX-generic and object-form metadata regression tests
NathanDrake2406 Jun 4, 2026
bbc758e
chore: remove unused ClientRuntimeMetadata type export
NathanDrake2406 Jun 4, 2026
538bcdc
revert: discard unrelated lockfile change from previous commit
NathanDrake2406 Jun 4, 2026
885d5e0
test: replace deploy test simulators with computeClientRuntimeMetadata
NathanDrake2406 Jun 4, 2026
a93ef71
Merge branch 'upstream/main' into nathan/next-dynamic-csp-nonce
NathanDrake2406 Jun 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions packages/vinext/src/build/client-build-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,12 +132,21 @@ export function createClientManualChunks(shimsDir: string) {
* compression efficiency — small files restart the compression dictionary,
* adding ~5-15% wire overhead vs fewer larger chunks.
*/
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 {
assetFileNames: createClientAssetFileNames(assetsDir),
...createClientFileNameConfig(assetsDir),
manualChunks: clientManualChunks,
experimentalMinChunkSize: 10_000,
};
Expand Down
9 changes: 9 additions & 0 deletions packages/vinext/src/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,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 / asset prefix already applied.
*/
// oxlint-disable-next-line no-var
var __VINEXT_DYNAMIC_PRELOADS__: Record<string, string[]> | undefined;

/**
* The client entry JS filename (e.g. `"_next/static/entry-abc123.js"`) for Pages
* Router builds.
Expand Down
116 changes: 56 additions & 60 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { manifestFilesWithBase } from "./utils/manifest-paths.js";
import { hasBasePath } from "./utils/base-path.js";
import { mergeRewriteQuery } from "./utils/query.js";
import {
Expand All @@ -106,6 +105,7 @@ import {
} from "./client/instrumentation-client-inject.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 {
Expand All @@ -123,11 +123,11 @@ import {
createLocalFontsPlugin,
} from "./plugins/fonts.js";
import { hasWranglerConfig, formatMissingCloudflarePluginError } from "./deploy.js";
import { computeLazyChunks } 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 {
createClientFileNameConfig,
createClientManualChunks,
createClientOutputConfig,
createClientCodeSplittingConfig,
Expand Down Expand Up @@ -613,13 +613,13 @@ const _reactServerShims = new Map<string, string>([
]);

const clientManualChunks = createClientManualChunks(_shimsDir);
const clientCodeSplittingConfig = createClientCodeSplittingConfig(clientManualChunks);

function getClientOutputConfigForVite(viteMajorVersion: number, assetsDir: string) {
return viteMajorVersion >= 8
? {
assetFileNames: createClientAssetFileNames(assetsDir),
codeSplitting: clientCodeSplittingConfig,
...createClientFileNameConfig(assetsDir),
codeSplitting: createClientCodeSplittingConfig(clientManualChunks),
}
: createClientOutputConfig(clientManualChunks, assetsDir);
}
Expand Down Expand Up @@ -2079,14 +2079,12 @@ 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,
// Client-scoped so RSC/SSR keep their normal asset handling
// unless the user configured Vite globally.
assetsInlineLimit: clientAssetsInlineLimit,
Expand Down Expand Up @@ -3884,6 +3882,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.
Expand Down Expand Up @@ -4413,14 +4417,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",
Expand All @@ -4443,25 +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). 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 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 = manifestFilesWithBase(computeLazyChunks(buildManifest), clientBase);
if (lazy.length > 0) lazyChunksData = lazy;
}
// 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<string, string[]> | null =
runtimeMetadata.dynamicPreloads ?? null;
let clientEntryFile: string | null = runtimeMetadata.clientEntryFile ?? null;

// Read SSR manifest for per-page CSS/JS injection
let ssrManifestData: Record<string, string[]> | null = null;
Expand All @@ -4477,10 +4479,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) {
Expand All @@ -4493,6 +4498,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);
}
Expand All @@ -4516,27 +4526,8 @@ 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
// `<prefix>/_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) {
if (clientEntryFile || ssrManifestData || lazyChunksData || dynamicPreloadsData) {
let code = fs.readFileSync(workerEntry, "utf-8");
const globals: string[] = [];
if (clientEntryFile) {
Expand All @@ -4554,6 +4545,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);
}
Expand Down
Loading
Loading