Files
cloudflare__vinext/tests/client-build-manifest.test.ts
T
Nathan Nguyen 08504a7745 fix(app-router): preload next/dynamic chunks with CSP nonce (#1594)
* 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.

* fix: honor assetPrefix for dynamic preload globals

* 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/.

* 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.

* 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.

* 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.

* 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.

* fix: address PR review feedback for next/dynamic CSP nonce

- Drop `as="style"` from `<link rel="stylesheet">` — `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.

* fix: update global.d.ts comment to mention asset prefix, fix formatting

* 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.

* 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.

* chore: remove unused ClientRuntimeMetadata type export

* revert: discard unrelated lockfile change from previous commit

* 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

* test: add absolute assetPrefix CSP nonce tests and clean up redundant globals

* fix(app-router): keep CSP nonce on next/dynamic preloads from Server Component call sites

DynamicPreloadChunks rendered in the environment of the dynamic() call site. For a Server Component call site it ran in the RSC environment, where the script-nonce React context is unavailable, so the emitted dynamic preload <link>s dropped the request nonce — a CSP violation under `script-src 'nonce-…' 'strict-dynamic'`. The existing tests missed this because every fixture called dynamic() from "use client" modules, which render in the SSR pass where the nonce is present.

Extract DynamicPreloadChunks into its own "use client" module (mirroring Next.js's PreloadChunks and vinext's next/script shim) so it always renders in the SSR pass where withScriptNonce() installs the provider, regardless of whether dynamic() is called from a Server or Client Component.

Add fixture regression tests: server-call-site JS and CSS preloads carry the nonce, an ssr:false boundary emits no server preload (Next.js loadable.tsx parity), and preloads are still emitted without a nonce when no CSP is set.

* test(app-router): add browser CSP-nonce e2e for Server Component next/dynamic call site

The existing csp.spec.ts browser test only exercises the client call-site page (/nextjs-compat/dynamic). Add a Playwright test for the Server Component call site (/nextjs-compat/dynamic/rsc-imports-client) asserting the lazily-loaded client widget hydrates under script-src 'nonce-…' 'strict-dynamic' with no CSP violations in the console.

* fix(app-router): correct next/dynamic preload key-spaces and harden the transform

Addresses review findings on the next/dynamic CSP-nonce feature.

- Lazy chunks must stay in the SSR-manifest key-space (basePath only); only dynamic preloads take the assetPrefix. Applying an absolute-URL assetPrefix to lazy chunks broke the Pages Router modulepreload-exclusion membership test and leaked lazy chunks into <link rel=modulepreload>.

- Resolve symlinks on both sides when computing a dynamic boundary's module ID (reusing relativeWithinRoot/tryRealpathSync), so the key matches the client manifest under pnpm/Cloudflare symlinked layouts instead of silently dropping the preload.

- Throw on dynamic() calls with >2 args (Next.js react-loadable parity).

- Insert the generated options arg right after the first arg, preserving comments/whitespace (the previous substring-comma scan ate commas inside comments).

- Recurse into _next/static/chunks/ in the on-disk client-entry fallback (client JS now lands under chunks/).

- Add typeof-window guard to DynamicPreloadChunks and document the deliberate as="style" omission for stylesheet links.

- Extract buildRuntimeGlobalsScript so the Cloudflare closeBundle injection shares one source of truth with the deploy tests (which now mirror includeClientEntry + the mixed app+pages branch).

Tests: lazy/dynamic key-space split incl. absolute-assetPrefix leak regression and a realistic already-prefixed manifest; mixed app+pages client-entry injection; symlinked module-id resolution; nested dynamic() calls; >2-arg throw; comment-safe insertion; ssr:false positive content assertion; honest dev-mode E2E naming (preload-nonce coverage lives in the prod-server Vitest suite).

* fix(app-router): fix parenthesized next/dynamic loader corruption + harden re-review findings

Follow-up to the re-review of 1709db35.

- Fix a regression where inserting the options arg at the first argument's AST end corrupted a parenthesized loader (dynamic((() => import('./x')))) into a sequence expression that DROPPED the loader. Insert at the call's closing paren (paren-safe) with a comment-aware trailing-comma check (keeps comment-safety, avoids ',,'). Verified all four arg shapes via the real transform.

- Add an end-to-end round-trip test: real computeClientRuntimeMetadata -> globalThis.__VINEXT_LAZY_CHUNKS__ -> real collectAssetTags, asserting a lazy chunk is excluded from modulepreload under basePath+assetPrefix (consumer-side guard for the lazy-chunk key-space fix).

- Replace the hand-rolled simulateAssetTagFiltering with the real exported collectAssetTags (kills drift + a stale 'in index.ts/can't import' comment).

- Add an on-disk client-entry fallback test for the real _next/static/chunks/ layout (prior tests only used the flat layout).

- Hardening: memoize root realpath in toManifestModuleId; .length/keys guards in buildRuntimeGlobalsScript; DEBUG-gated parse-failure log; line:col in the >2-arg error; document the preserveSymlinks:false dependency; negative test for a shadowed dynamic(a,b,c) not throwing; ssr:false asserts visible text; honest typeof-window guard + E2E comments.

* test(app-router): fix stale comments + harden next/dynamic tests (3rd review pass)

Addresses the 3rd re-review of the next/dynamic CSP-nonce work. No runtime behavior change (doc + test hardening + one behavior-neutral nonce-prop cleanup).

- Fix two stale comments claiming DynamicPreloadChunks is NOT 'use client' (the opposite of the implemented, correct behavior) in the prod-server test and the rsc-imports-client fixture — leftovers from when the bug was first reproduced.

- ssr:false / assetPrefix tests: match the fetchPriority attribute case-INSENSITIVELY so a future React lowercasing can't make the ssr:false negative assertion pass vacuously.

- Add bare-promise dynamic(import(...)) transform tests (+ parenthesized variant) via a shared firstDynamicCallArgTypes AST helper; strengthen the double-comma test with an exact-output assertion.

- Make the basePath+assetPrefix round-trip test assert lazy-exclusion by basename, not the exact modulepreload href (that URL is subject to a separate, pre-existing collectAssetTags asset-URL bug).

- Nits: 'absent/undefined map' wording; unify JS/CSS nonce-prop handling; document the process-lifetime root-realpath cache.

Investigation note: a sub-agent confirmed a separate PRE-EXISTING bug (out of scope here): under basePath + a distinct path-style assetPrefix, collectAssetTags emits modulepreload hrefs as /<basePath>/<assetPrefix>/_next/... (404) while assets serve at /<assetPrefix>/_next/...; DynamicPreloadChunks already emits the correct assetPrefix-only URL.

* fix(pages-router): serve assets from assetPrefix (not basePath) when both are set

When basePath and a distinct path/absolute assetPrefix were both configured, the Pages Router emitted asset hrefs as /<basePath>/<assetPrefix>/_next/... (and the client-nav pageModuleUrl likewise), which 404 — assets actually serve at /<assetPrefix>/_next/... . assetPrefix REPLACES basePath for asset URLs in Next.js; App Router (React bootstrapModules) and next/dynamic preloads were already correct — only the Pages Router collectAssetTags / resolveClientModuleUrl paths were wrong.

- Add assetServingUrlFromBaseAnchored(value, basePath, assetPrefix): strips the basePath segment from a base-anchored SSR-manifest value and re-anchors under the assetPrefix (no-op when assetPrefix is unset). Mirrors how next/dynamic preloads are computed.

- collectAssetTags: render <link>/<script> hrefs via the re-anchoring helper while KEEPING the base-anchored value for the lazy-chunk membership test + dedup. Thread basePath/assetPrefix from vinextConfig.

- resolveClientModuleUrl (pageModuleUrl/appModuleUrl, import()ed client-side for navigation): re-anchor the same way.

Tests: collectAssetTags re-anchoring unit tests (path/absolute assetPrefix, client entry, base-only no-op, lazy-exclusion still keyed on the base-anchored value) + an end-to-end Pages Router build with basePath:'/docs' + assetPrefix:'/cdn' that fetches every emitted asset URL and asserts 200 (the previously-missing guard). Found via the sub-agent investigation in the prior review round.

* chore(app-router): address bonk review comments on next/dynamic preloads

No behavior change — review-comment cleanup (comments + one behavior-neutral refactor).

- dynamic-preload-metadata: honor the ResolveDynamicImport `importer` arg in the plugin resolver closure instead of closing over `id` (equivalent today; removes the signature/impl footgun bonk flagged).

- dynamic-preload-metadata: expand the dynamicLoaderNode `?? modules` comment to explain the react-loadable parity (it's a harmless no-op for string-array modules), per the bonk note raised twice.

- lazy-chunks: document that computeDynamicImportPreloads intentionally does NOT subtract shared eager chunks from a boundary's preload set — harmless (browser/ReactDOM.preload/React stylesheet model all dedupe by URL) and mirrors Next.js's per-module file listing (the bonk 'redundant preloads' observation).

Already-resolved bonk items verified: the redundant installClientBuildManifestGlobals call is already guarded behind the App-Router-only else branch (prod-server.ts), and the absolute-assetPrefix CSP test already asserts the nonce on cross-origin https://cdn… preload hrefs.

---------

Co-authored-by: James <james@eli.cx>
2026-06-11 00:59:18 +01:00

530 lines
17 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
import fsp from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
findClientEntryFile,
findClientEntryFileFromManifest,
findPagesClientEntryFile,
findPagesClientEntryFileFromManifest,
readClientBuildManifest,
} from "../packages/vinext/src/utils/client-build-manifest.js";
import {
buildNextClientBuildManifestContent,
buildNextClientSsgManifestContent,
emitNextClientRuntimeManifests,
} from "../packages/vinext/src/build/next-client-runtime-manifests.js";
import {
findClientEntryFileFromVinextManifest,
findPagesClientEntryFileFromVinextManifest,
readClientEntryManifest,
VINEXT_CLIENT_ENTRY_MANIFEST,
} from "../packages/vinext/src/utils/client-entry-manifest.js";
import { computeClientRuntimeMetadata } from "../packages/vinext/src/utils/client-runtime-metadata.js";
describe("client build manifest helpers", () => {
let tmpDir: string;
let clientDir: string;
beforeEach(async () => {
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-client-build-manifest-"));
clientDir = path.join(tmpDir, "client");
await fsp.mkdir(path.join(clientDir, ".vite"), { recursive: true });
});
afterEach(async () => {
await fsp.rm(tmpDir, { recursive: true, force: true });
});
it("reads Vite manifest entries used for client entry and lazy chunk computation", async () => {
const manifestPath = path.join(clientDir, ".vite", "manifest.json");
await fsp.writeFile(
manifestPath,
JSON.stringify({
"pages-client-entry.ts": {
file: "_next/static/vinext-client-entry-abcd.js",
isEntry: true,
imports: ["shared"],
dynamicImports: ["lazy"],
css: ["_next/static/client.css"],
assets: ["_next/static/logo.svg"],
},
}),
);
const manifest = readClientBuildManifest(manifestPath);
expect(manifest).toEqual({
"pages-client-entry.ts": {
file: "_next/static/vinext-client-entry-abcd.js",
isEntry: true,
imports: ["shared"],
dynamicImports: ["lazy"],
css: ["_next/static/client.css"],
assets: ["_next/static/logo.svg"],
},
});
});
it("finds the client entry from the manifest before scanning disk", () => {
const entry = findClientEntryFileFromManifest(
{
"pages-client-entry.ts": {
file: "_next/static/vinext-client-entry-abcd.js",
isEntry: true,
},
},
"/docs/",
);
expect(entry).toBe("docs/_next/static/vinext-client-entry-abcd.js");
});
it("prefers the marked client entry over other isEntry chunks regardless of order", () => {
const entry = findClientEntryFileFromManifest(
{
"instrumentation-client.ts": {
file: "_next/static/vinext-instrumentation-client-0001.js",
isEntry: true,
},
"pages-client-entry.ts": {
file: "_next/static/vinext-client-entry-abcd.js",
isEntry: true,
},
},
"/",
);
expect(entry).toBe("_next/static/vinext-client-entry-abcd.js");
});
it("prefers the Pages client entry over the App browser entry in hybrid manifests", () => {
const entry = findClientEntryFileFromManifest(
{
"virtual:vinext-app-browser-entry": {
file: "_next/static/vinext-app-browser-entry-0001.js",
isEntry: true,
},
"virtual:vinext-client-entry": {
file: "_next/static/vinext-client-entry-abcd.js",
isEntry: true,
},
},
"/",
);
expect(entry).toBe("_next/static/vinext-client-entry-abcd.js");
});
it("finds only the Pages client entry when requested for Pages fallback rendering", () => {
const manifest = {
"virtual:vinext-app-browser-entry": {
file: "_next/static/vinext-app-browser-entry-0001.js",
isEntry: true,
},
"virtual:vinext-client-entry": {
file: "_next/static/vinext-client-entry-abcd.js",
isEntry: true,
},
};
expect(findPagesClientEntryFileFromManifest(manifest, "/")).toBe(
"_next/static/vinext-client-entry-abcd.js",
);
});
it("reads vinext's entry manifest for hashed client entry names", async () => {
await fsp.writeFile(
path.join(clientDir, VINEXT_CLIENT_ENTRY_MANIFEST),
JSON.stringify({
appBrowserEntry: "_next/static/index-app.js",
pagesClientEntry: "_next/static/index-pages.js",
}),
);
const manifest = readClientEntryManifest(clientDir);
expect(findClientEntryFileFromVinextManifest(manifest, "/docs/")).toBe(
"docs/_next/static/index-pages.js",
);
expect(findPagesClientEntryFileFromVinextManifest(manifest, "/")).toBe(
"_next/static/index-pages.js",
);
});
it("falls back to the on-disk assets directory when the manifest has no entry", async () => {
const assetsSubdir = "_next/static";
await fsp.mkdir(path.join(clientDir, assetsSubdir), { recursive: true });
await fsp.writeFile(path.join(clientDir, assetsSubdir, "shared.js"), "");
await fsp.writeFile(path.join(clientDir, assetsSubdir, "vinext-client-entry-1234.js"), "");
const entry = findClientEntryFile({
buildManifest: {},
clientDir,
assetsSubdir,
assetBase: "/docs/",
});
expect(entry).toBe("docs/_next/static/vinext-client-entry-1234.js");
});
it("uses the asset-prefix assets subdirectory for fallback entry lookup", async () => {
const assetsSubdir = "cdn/_next/static";
await fsp.mkdir(path.join(clientDir, assetsSubdir), { recursive: true });
await fsp.writeFile(path.join(clientDir, assetsSubdir, "vinext-client-entry-5678.js"), "");
const entry = findClientEntryFile({
clientDir,
assetsSubdir,
assetBase: "/",
});
expect(entry).toBe("cdn/_next/static/vinext-client-entry-5678.js");
});
it("emits Next.js runtime manifests under the configured assets directory and build ID", async () => {
emitNextClientRuntimeManifests({
clientDir,
assetsSubdir: "base/_next/static",
buildId: "build-123",
rewrites: { beforeFiles: [], afterFiles: [], fallback: [] },
});
const buildManifest = await fsp.readFile(
path.join(clientDir, "base", "_next", "static", "build-123", "_buildManifest.js"),
"utf-8",
);
expect(buildManifest).toContain("self.__BUILD_MANIFEST = ");
expect(buildManifest).toContain("__BUILD_MANIFEST_CB");
const ssgManifest = await fsp.readFile(
path.join(clientDir, "base", "_next", "static", "build-123", "_ssgManifest.js"),
"utf-8",
);
expect(ssgManifest).toBe(buildNextClientSsgManifestContent());
});
it("normalizes rewrites in the Next.js runtime build manifest", () => {
const content = buildNextClientBuildManifestContent({
beforeFiles: [
{
source: "/internal/:path*",
destination: "/rewritten/:path*",
has: [{ type: "header", key: "x-test", value: "1" }],
},
],
afterFiles: [
{
source: "/external",
destination: "https://example.com/external",
},
],
fallback: [
{
source: "/only-without-cookie",
destination: "/target",
missing: [{ type: "cookie", key: "seen" }],
},
],
});
expect(content).toContain('"source":"/internal/:path*"');
expect(content).toContain('"destination":"/rewritten/:path*"');
expect(content).toContain('"source":"/external"');
expect(content).not.toContain("https://example.com/external");
expect(content).toContain('"sortedPages":[]');
expect(content).not.toContain('"missing"');
});
it("prefers the Pages client entry over the App browser entry in on-disk fallback lookup", async () => {
const assetsSubdir = "_next/static";
await fsp.mkdir(path.join(clientDir, assetsSubdir), { recursive: true });
await fsp.writeFile(path.join(clientDir, assetsSubdir, "vinext-app-browser-entry-1234.js"), "");
await fsp.writeFile(path.join(clientDir, assetsSubdir, "vinext-client-entry-5678.js"), "");
const entry = findClientEntryFile({
clientDir,
assetsSubdir,
assetBase: "/",
});
expect(entry).toBe("_next/static/vinext-client-entry-5678.js");
});
it("does not return the App browser entry for Pages fallback lookup", async () => {
const assetsSubdir = "_next/static";
await fsp.mkdir(path.join(clientDir, assetsSubdir), { recursive: true });
await fsp.writeFile(path.join(clientDir, assetsSubdir, "vinext-app-browser-entry-1234.js"), "");
const entry = findPagesClientEntryFile({
clientDir,
assetsSubdir,
assetBase: "/",
});
expect(entry).toBeUndefined();
});
});
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,
});
// lazyChunks stay base-only (SSR-manifest key-space for the modulepreload
// exclusion); only dynamicPreloads get the assetPrefix.
expect(result).toEqual({
clientEntryFile: "_next/static/entry-abc123.js",
lazyChunks: ["_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,
});
// lazyChunks must NOT take an absolute-URL prefix — the Pages Router
// modulepreload-exclusion compares them against base-relative SSR-manifest
// values, so an absolute URL here would leak lazy chunks into modulepreload.
// Only dynamicPreloads (real <link> hrefs) get the absolute prefix.
expect(result).toEqual({
clientEntryFile: "_next/static/entry-abc123.js",
lazyChunks: ["_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("falls back to on-disk client entry under _next/static/chunks/ (real build layout)", async () => {
// The real build emits client JS under `_next/static/chunks/`
// (createClientFileNameConfig), so the on-disk fallback must recurse into it.
// A flat scan of `_next/static` would miss the entry entirely.
await fsp.mkdir(path.join(clientDir, "_next", "static", "chunks"), { recursive: true });
await fsp.writeFile(
path.join(clientDir, "_next", "static", "chunks", "vinext-client-entry-abcd.js"),
"",
);
await fsp.writeFile(
path.join(clientDir, ".vite", "manifest.json"),
JSON.stringify({
"lazy-chunk.js": {
file: "_next/static/chunks/lazy-1234.js",
isDynamicEntry: true,
},
}),
);
const result = computeClientRuntimeMetadata({
clientDir,
assetBase: "/",
assetPrefix: "",
includeClientEntry: true,
});
expect(result.clientEntryFile).toBe("_next/static/chunks/vinext-client-entry-abcd.js");
});
it("normalises lazy chunks (basePath only) and dynamic preloads (assetPrefix) into distinct key-spaces", async () => {
await fsp.writeFile(
path.join(clientDir, ".vite", "manifest.json"),
JSON.stringify(baseManifest),
);
const result = computeClientRuntimeMetadata({
clientDir,
assetBase: "/docs/",
assetPrefix: "/cdn",
includeClientEntry: true,
});
// Regression guard for the absolute/path assetPrefix lazy-chunk leak: the two
// consumers expect DIFFERENT key-spaces, so the same source chunk is
// normalised differently. lazyChunks keep the SSR-manifest key-space
// (basePath only) so the Pages Router modulepreload-exclusion membership test
// matches; dynamicPreloads get the full assetPrefix because they render real
// <link> hrefs.
expect(result.lazyChunks).toEqual(["docs/_next/static/lazy-ghi789.js"]);
expect(result.dynamicPreloads!.LazyComponent).toContain("cdn/_next/static/lazy-ghi789.js");
expect(result.dynamicPreloads!.LazyComponent).not.toContain("docs/_next/static/lazy-ghi789.js");
});
});