mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
3a1e56748f
* 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>
267 lines
8.6 KiB
TypeScript
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();
|
|
});
|
|
});
|