mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
930e03c449
* fix(app-router): prerender cacheComponents root-param fallback shells PR 1 of 4: core model + build integration. Model: app-ppr-fallback-shell, pregenerated-concrete-paths, prerender-manifest Build: prerender/run-prerender fallback shell artifact generation Tests: createAppPprFallbackShells, pregenerated-concrete-paths core * feat(ppr): add fallback shell payload identity * feat(ppr): safely serve fallback shell cache entries * refactor(ppr): extract fallback shell render pipeline and dedupe getViteMajorVersion * fix(ppr): restore safe-serving CI checks The safe-serving branch failed CI because a client-side server-only guard rejected valid 'use server' action modules, a fallback-shell dependency cast no longer matched its helper type, apps/web lost the Cloudflare cache adapter source path during typecheck, and Knip could not resolve the documented Cloudflare cache adapter subpaths. The server-only guard now reads the directive prologue and skips top-level 'use server' modules, fallback-shell regeneration passes an explicit typed dependency object, apps/web gets the workspace Cloudflare cache path, the Cloudflare package exports explicit adapter subpaths, Knip uses a relative source import in the dispatch unit test, and the readiness test awaits the actual cache-ready promise. * fix(ppr): guard fallback shell reads for queries * fix(ppr): normalize pregenerated concrete paths * fix(ppr): address review comments on fallback shell serving and react runtime check * fix(ci): restore knip ignores for unlisted binaries and prune dependencies * fix(ppr): restrict bracket check in fallback detection to legacy manifests * fix(ppr): prefer stable ESM react-dom/static.edge before CJS fallback The loadStaticPrerender dev-mode path hard-codes react-dom's internal CJS file layout (cjs/react-dom-server.edge.development.js). A React upgrade could silently break fallback-shell rendering in dev without affecting production because the function never tried the stable ESM entry in development. Change the order so that react-dom/static.edge is attempted first in all environments. Only fall back to the CJS path when the ESM entry does not export prerender() and we are in a development runtime. This lets future React dev builds that expose prerender() from the ESM entry work automatically, and it surfaces the CJS-path failure only when the stable path is genuinely unavailable. Also handle the CJS default-export interop shape that Node.js ESM produces when dynamically importing a CommonJS module. * refactor(ppr): address four review cleanup items 1. Remove redundant double-normalization in seed-cache.ts addPregeneratedConcretePath already normalizes internally, so the call-site normalizePregeneratedPathname() was redundant. Dropping it gives a single source of truth for pathname normalization. 2. Lazily construct FallbackShellRenderDeps in tryServePprFallbackShell Extract the large FallbackShellRenderDeps object into a top-level buildFallbackShellRenderDeps helper. The closure in the probe loop now just delegates, so the heavy object is only created when a STALE entry actually triggers regeneration, not on every HIT probe. 3. Add dev-only warning for legacy bracket heuristic When isFallbackShellArtifactPath falls back to the bracket substring scan (route.fallback === undefined), emit a console.warn in dev so developers know a legacy manifest is being used and concrete URLs with literal brackets may be misclassified. 4. Update pregenerated-concrete-paths.test.ts to colon notation The production manifest writer uses colon notation (/:slug) for the route field, but the test fixtures used bracket notation (/[slug]). Update the fixtures so the test format matches production, exercising the real key format that the safe-serving guard depends on. * test(ppr): compact fallback shell dispatch fixtures * refactor(ppr): name fallback shell render phases * refactor(app-router): deduplicate PPR fallback shell and app page cache render paths * fix(ppr): avoid serving incomplete fallback shells * chore: drop redundant cloudflare cache aliases * fix(ppr): track fallback navigation hooks * test(ppr): render navigation hook probes * test(ppr): type navigation hook probes * refactor(ppr): remove unused shell regeneration path * refactor(ppr): remove unused shell result type * fix(ppr): safely gate fallback shell prerendering * fix(ppr): keep fallback shell gate internal * fix(ppr): mark generated dynamic fallback shells * test(ppr): align fallback fixture and search params --------- Co-authored-by: James <james@eli.cx>
340 lines
10 KiB
TypeScript
340 lines
10 KiB
TypeScript
import { describe, expect, it } from "vite-plus/test";
|
|
import {
|
|
encodePrerenderRouteParams,
|
|
matchPrerenderRouteParamsPayload,
|
|
prerenderRouteParamsPayloadMatchesRoute,
|
|
type PrerenderRouteParamsPayload,
|
|
} from "../packages/vinext/src/server/prerender-route-params.js";
|
|
|
|
describe("prerenderRouteParamsPayloadMatchesRoute", () => {
|
|
it("requires the decoded prerender params to match the final route params", () => {
|
|
const payload: PrerenderRouteParamsPayload = {
|
|
routePattern: "/product/:id",
|
|
params: { id: "sticks%20%26%20stones" },
|
|
};
|
|
|
|
expect(
|
|
prerenderRouteParamsPayloadMatchesRoute(payload, "/product/:id", {
|
|
id: "sticks & stones",
|
|
}),
|
|
).toBe(true);
|
|
expect(
|
|
prerenderRouteParamsPayloadMatchesRoute(payload, "/product/:id", {
|
|
id: "sticks-and-stones",
|
|
}),
|
|
).toBe(false);
|
|
expect(
|
|
prerenderRouteParamsPayloadMatchesRoute(payload, "/source/:slug", {
|
|
id: "sticks & stones",
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("compares catch-all params element-by-element after decoding", () => {
|
|
const payload: PrerenderRouteParamsPayload = {
|
|
routePattern: "/docs/:slug+",
|
|
params: { slug: ["sticks%20%26%20stones", "more%20words"] },
|
|
};
|
|
|
|
expect(
|
|
prerenderRouteParamsPayloadMatchesRoute(payload, "/docs/:slug+", {
|
|
slug: ["sticks & stones", "more words"],
|
|
}),
|
|
).toBe(true);
|
|
expect(
|
|
prerenderRouteParamsPayloadMatchesRoute(payload, "/docs/:slug+", {
|
|
slug: ["more words", "sticks & stones"],
|
|
}),
|
|
).toBe(false);
|
|
expect(
|
|
prerenderRouteParamsPayloadMatchesRoute(payload, "/docs/:slug+", {
|
|
slug: "sticks & stones",
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("rejects a payload whose fallbackParamNames contain a param not present in the route pattern", () => {
|
|
const payload: PrerenderRouteParamsPayload = {
|
|
routePattern: "/product/:id",
|
|
params: { id: "abc" },
|
|
fallbackParamNames: ["id", "slug"],
|
|
};
|
|
|
|
expect(
|
|
prerenderRouteParamsPayloadMatchesRoute(payload, "/product/:id", {
|
|
id: "abc",
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("rejects a payload whose fallbackParamNames contain duplicates", () => {
|
|
const payload: PrerenderRouteParamsPayload = {
|
|
routePattern: "/product/:id",
|
|
params: { id: "abc" },
|
|
fallbackParamNames: ["id", "id"],
|
|
};
|
|
|
|
expect(
|
|
prerenderRouteParamsPayloadMatchesRoute(payload, "/product/:id", {
|
|
id: "abc",
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("returns false for a valid fallback-shell match because only exact matches are accepted", () => {
|
|
const payload: PrerenderRouteParamsPayload = {
|
|
routePattern: "/product/:id",
|
|
params: { id: "abc" },
|
|
fallbackParamNames: ["id"],
|
|
};
|
|
|
|
expect(
|
|
prerenderRouteParamsPayloadMatchesRoute(payload, "/product/:id", {
|
|
id: "abc",
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("matchPrerenderRouteParamsPayload", () => {
|
|
it("returns kind exact when payload has no fallbackParamNames", () => {
|
|
const payload: PrerenderRouteParamsPayload = {
|
|
routePattern: "/:locale/blog/:slug",
|
|
params: { locale: "en", slug: "hello%20world" },
|
|
};
|
|
|
|
expect(
|
|
matchPrerenderRouteParamsPayload(payload, "/:locale/blog/:slug", {
|
|
locale: "en",
|
|
slug: "hello world",
|
|
}),
|
|
).toEqual({ kind: "exact", params: { locale: "en", slug: "hello%20world" } });
|
|
});
|
|
|
|
it("returns kind fallback-shell when payload has fallbackParamNames", () => {
|
|
const payload: PrerenderRouteParamsPayload = {
|
|
routePattern: "/:locale/blog/:slug",
|
|
params: { locale: "en", slug: "%5Bslug%5D" },
|
|
fallbackParamNames: ["slug"],
|
|
};
|
|
|
|
expect(
|
|
matchPrerenderRouteParamsPayload(payload, "/:locale/blog/:slug", {
|
|
locale: "en",
|
|
slug: "[slug]",
|
|
}),
|
|
).toEqual({
|
|
fallbackParamNames: ["slug"],
|
|
kind: "fallback-shell",
|
|
params: { locale: "en", slug: "%5Bslug%5D" },
|
|
});
|
|
});
|
|
|
|
it("rejects fallback-shell payloads that name params outside the route pattern", () => {
|
|
const payload: PrerenderRouteParamsPayload = {
|
|
routePattern: "/:locale/blog/:slug",
|
|
params: { locale: "en", slug: "%5Bslug%5D" },
|
|
fallbackParamNames: ["missing"],
|
|
};
|
|
|
|
expect(
|
|
matchPrerenderRouteParamsPayload(payload, "/:locale/blog/:slug", {
|
|
locale: "en",
|
|
slug: "[slug]",
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("matches fallback-shell catch-all placeholders as route param arrays", () => {
|
|
const payload: PrerenderRouteParamsPayload = {
|
|
routePattern: "/:locale/docs/:slug+",
|
|
params: { locale: "fr", slug: ["%5B...slug%5D"] },
|
|
fallbackParamNames: ["slug"],
|
|
};
|
|
|
|
expect(
|
|
matchPrerenderRouteParamsPayload(payload, "/:locale/docs/:slug+", {
|
|
locale: "fr",
|
|
slug: ["[...slug]"],
|
|
}),
|
|
).toEqual({
|
|
fallbackParamNames: ["slug"],
|
|
kind: "fallback-shell",
|
|
params: { locale: "fr", slug: ["%5B...slug%5D"] },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("encodePrerenderRouteParams", () => {
|
|
it("encodes exact params without fallbackParamNames", () => {
|
|
const result = encodePrerenderRouteParams("/product/:id", { id: "abc" });
|
|
|
|
expect(result).toEqual({
|
|
routePattern: "/product/:id",
|
|
params: { id: "abc" },
|
|
});
|
|
});
|
|
|
|
it("encodes fallback-shell params with fallbackParamNames", () => {
|
|
const result = encodePrerenderRouteParams(
|
|
"/:locale/blog/:slug",
|
|
{ locale: "en", slug: "[slug]" },
|
|
["slug"],
|
|
);
|
|
|
|
expect(result).toEqual({
|
|
fallbackParamNames: ["slug"],
|
|
routePattern: "/:locale/blog/:slug",
|
|
params: { locale: "en", slug: "%5Bslug%5D" },
|
|
});
|
|
});
|
|
|
|
it("omits fallbackParamNames when the array is empty", () => {
|
|
const payload = encodePrerenderRouteParams("/product/:id", { id: "abc" }, []);
|
|
|
|
expect(payload).toEqual({
|
|
routePattern: "/product/:id",
|
|
params: { id: "abc" },
|
|
});
|
|
});
|
|
|
|
it("returns null when there are no dynamic params", () => {
|
|
expect(encodePrerenderRouteParams("/about", {})).toBe(null);
|
|
});
|
|
|
|
it("returns null when there are no dynamic params even with fallbackParamNames", () => {
|
|
expect(encodePrerenderRouteParams("/about", {}, ["id"])).toBe(null);
|
|
});
|
|
|
|
it("percent-encodes param values", () => {
|
|
const result = encodePrerenderRouteParams("/:locale/blog/:slug", {
|
|
locale: "en",
|
|
slug: "hello world & more",
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
routePattern: "/:locale/blog/:slug",
|
|
params: { locale: "en", slug: "hello%20world%20%26%20more" },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("matchPrerenderRouteParamsPayload", () => {
|
|
it("returns kind exact when payload has no fallbackParamNames", () => {
|
|
const payload: PrerenderRouteParamsPayload = {
|
|
routePattern: "/:locale/blog/:slug",
|
|
params: { locale: "en", slug: "hello%20world" },
|
|
};
|
|
|
|
expect(
|
|
matchPrerenderRouteParamsPayload(payload, "/:locale/blog/:slug", {
|
|
locale: "en",
|
|
slug: "hello world",
|
|
}),
|
|
).toEqual({ kind: "exact", params: { locale: "en", slug: "hello%20world" } });
|
|
});
|
|
|
|
it("returns kind fallback-shell when payload has fallbackParamNames", () => {
|
|
const payload: PrerenderRouteParamsPayload = {
|
|
routePattern: "/:locale/blog/:slug",
|
|
params: { locale: "en", slug: "%5Bslug%5D" },
|
|
fallbackParamNames: ["slug"],
|
|
};
|
|
|
|
expect(
|
|
matchPrerenderRouteParamsPayload(payload, "/:locale/blog/:slug", {
|
|
locale: "en",
|
|
slug: "[slug]",
|
|
}),
|
|
).toEqual({
|
|
fallbackParamNames: ["slug"],
|
|
kind: "fallback-shell",
|
|
params: { locale: "en", slug: "%5Bslug%5D" },
|
|
});
|
|
});
|
|
|
|
it("rejects fallback-shell payloads that name params outside the route pattern", () => {
|
|
const payload: PrerenderRouteParamsPayload = {
|
|
routePattern: "/:locale/blog/:slug",
|
|
params: { locale: "en", slug: "%5Bslug%5D" },
|
|
fallbackParamNames: ["missing"],
|
|
};
|
|
|
|
expect(
|
|
matchPrerenderRouteParamsPayload(payload, "/:locale/blog/:slug", {
|
|
locale: "en",
|
|
slug: "[slug]",
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("matches fallback-shell catch-all placeholders as route param arrays", () => {
|
|
const payload: PrerenderRouteParamsPayload = {
|
|
routePattern: "/:locale/docs/:slug+",
|
|
params: { locale: "fr", slug: ["%5B...slug%5D"] },
|
|
fallbackParamNames: ["slug"],
|
|
};
|
|
|
|
expect(
|
|
matchPrerenderRouteParamsPayload(payload, "/:locale/docs/:slug+", {
|
|
locale: "fr",
|
|
slug: ["[...slug]"],
|
|
}),
|
|
).toEqual({
|
|
fallbackParamNames: ["slug"],
|
|
kind: "fallback-shell",
|
|
params: { locale: "fr", slug: ["%5B...slug%5D"] },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("encodePrerenderRouteParams", () => {
|
|
it("encodes exact params without fallbackParamNames", () => {
|
|
const result = encodePrerenderRouteParams("/product/:id", { id: "abc" });
|
|
expect(result).toEqual({
|
|
routePattern: "/product/:id",
|
|
params: { id: "abc" },
|
|
});
|
|
});
|
|
|
|
it("encodes fallback-shell params with fallbackParamNames", () => {
|
|
const result = encodePrerenderRouteParams(
|
|
"/:locale/blog/:slug",
|
|
{ locale: "en", slug: "[slug]" },
|
|
["slug"],
|
|
);
|
|
expect(result).toEqual({
|
|
fallbackParamNames: ["slug"],
|
|
routePattern: "/:locale/blog/:slug",
|
|
params: { locale: "en", slug: "%5Bslug%5D" },
|
|
});
|
|
});
|
|
|
|
it("returns null for static patterns with no dynamic params", () => {
|
|
expect(encodePrerenderRouteParams("/about", {})).toBeNull();
|
|
});
|
|
|
|
it("percent-encodes param values", () => {
|
|
const result = encodePrerenderRouteParams("/:locale/blog/:slug", {
|
|
locale: "en",
|
|
slug: "hello world & more",
|
|
});
|
|
expect(result).toEqual({
|
|
routePattern: "/:locale/blog/:slug",
|
|
params: { locale: "en", slug: "hello%20world%20%26%20more" },
|
|
});
|
|
});
|
|
|
|
it("omits fallbackParamNames when empty array is passed", () => {
|
|
const result = encodePrerenderRouteParams(
|
|
"/:locale/blog/:slug",
|
|
{ locale: "en", slug: "post" },
|
|
[],
|
|
);
|
|
expect(result).toEqual({
|
|
routePattern: "/:locale/blog/:slug",
|
|
params: { locale: "en", slug: "post" },
|
|
});
|
|
});
|
|
});
|