Files
cloudflare__vinext/tests/client-build-manifest.test.ts
T
Nathan Nguyen 3a1e56748f fix(app-router): match Pages navigation params in hybrid builds (#1741)
* fix(app-router): emit pages client entry for hybrid builds

* fix pages router navigation params readiness

* fix(pages-router): track hashed fallback client entry

* fix(pages-router): honor navigation readiness in hooks

* fix(pages-router): derive server router.isReady from SSR readiness

Address review feedback on #1741:

- getRouterSnapshot() now reads navigationIsReady from the SSR context on
  the server instead of unconditionally returning true. For pre-ready Pages
  routes (auto-export dynamic / query-string / rewrite-capable builds) this
  keeps useRouter().isReady consistent between SSR and client hydration,
  avoiding a hydration mismatch for components that read it in JSX. Mirrors
  Next.js render.tsx's server readiness rule.
- Drop the redundant guard branch in getClientParamsSnapshot.
- Add a regression test asserting server useRouter().isReady reflects the
  SSR navigationIsReady context.

* fix(pages-router): align param resolution and dev/prod readiness parity

Address review feedback (ask-bonk + self-review) on #1741:

- Align client Pages navigation params to query-first, matching the server
  snapshot and Next.js's adaptForPathParams (derives path params from
  router.query). __NEXT_DATA__.query is kept current on client navigation, so
  the two snapshots are now provably identical and avoid a latent post-
  hydration mismatch.
- Populate window.__VINEXT_PAGE_PATTERNS__ in the dev server (own script tag)
  so the next/navigation compat hooks can resolve a dynamic pattern from a
  resolved path in dev exactly as in production. Closes a dev/prod parity gap
  (prod set this via the client entry; dev never did).
- Drop the redundant pre-readiness setSSRContext in dev; the single post-
  module-load call now publishes navigationIsReady before rendering, matching
  the prod handler.
- Document that installPagesClientAssetGlobals is intentionally inert for
  App-only builds, and that getReadonlyPagesSearchParams' string-keyed cache
  is safe to share across concurrent SSR requests (immutable wrappers).
- Add a dev regression test asserting __VINEXT_PAGE_PATTERNS__ exposure.

* fix(pages-router): prefer URL params for client snapshots

* fix(app-router): gate pages asset globals

* test(app-router): add production test for hybrid basePath + assetPrefix

* fix(navigation): widen usePathname return type to string | null

* refactor(pages-router): dedupe navigation readiness modeling and cache pattern scan

Addresses code-review cleanup findings on the Pages Router navigation
readiness work:

- Extract buildPagesReadinessNextData() so the dev SSR handler and the
  production Pages page handler derive the gssp/gsp/gip/appGip/autoExport
  __NEXT_DATA__ readiness flags from one place, instead of two hand-kept
  copies that could drift and produce a dev/prod hydration mismatch.
- Extract resolvePagesNavigationPathname() shared by the client and SSR
  branches of getPagesNavigationContext, so the pathname null-ness rule
  (fallback / pre-ready auto-export dynamic) cannot diverge between them.
- Memoize resolvePagesRoutePatternForPath()'s pattern scan, which ran an
  uncached O(routes) loop on every useSyncExternalStore snapshot read.
- In getServerSearchParamsSnapshot, serialize the source only when the
  object-identity fast path misses, avoiding a toString() on every read.

* refactor(pages-router): consistent ISR readiness data + robustness cleanups

Addresses further code-review findings:

- Thread the readiness flags (gssp/gsp/gip/appGip/autoExport) through the ISR
  background-regeneration path: renderPagesIsrHtml now serializes the same
  __NEXT_DATA__ readiness state the initial render emits, so a regenerated,
  re-cached page hydrates with the identical initial router.isReady the server
  computed. Previously these flags were dropped on that path — benign today
  (ISR-eligible getStaticProps routes never trigger the flag-gated early-ready
  branches) but a latent dev/prod hydration-readiness drift. Adds a shared
  PagesNextDataExtras type so every render path stays in lockstep.
- readSsrManifest: degrade a malformed/unparseable SSR manifest to "no SSR
  manifest" with a warning instead of throwing and aborting server startup,
  restoring the previous tolerant behavior.
- pages-readiness: project PagesReadinessNextData from the canonical
  VinextNextData and accept PagesPageModule, instead of bespoke types that
  could drift from the __NEXT_DATA__ shape.
- getReadonlyPagesSearchParams: drop the redundant string-keyed cache slot; the
  per-object WeakMap is authoritative and concurrency-safe on its own.
- isPagesRouterReady: drop the redundant server-side window check.

* fix(pages-router): restore string-keyed readonly search-params cache

Reverts the cache simplification from the previous commit. The string-keyed
slot is load-bearing, not redundant: across the Pages Router pre-ready → ready
transition the navigation context swaps in a new URLSearchParams instance even
when the query string is unchanged, so the per-object WeakMap alone returns a
fresh ReadonlyURLSearchParams wrapper and useSearchParams() loses Object.is
stability — re-firing a [searchParams] effect. Caught by the rewrite-driven
pre-ready static page e2e (search-params-snapshots saw ['',''] not ['']).
Keeps the improved comment explaining why both cache levels are needed.

---------

Co-authored-by: James <james@eli.cx>
2026-06-09 11:30:38 +01:00

267 lines
8.6 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";
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();
});
});