Files
cloudflare__vinext/tests/app-ppr-fallback-shell.test.ts
T
Nathan Nguyen 930e03c449 fix(ppr): gate fallback shells until request-time resume is supported (#1716)
* 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>
2026-06-14 00:44:54 +01:00

166 lines
5.6 KiB
TypeScript

import { describe, expect, it } from "vite-plus/test";
import {
createAppPprFallbackShell,
createAppPprFallbackShells,
isAppPprDynamicFallbackShellHtml,
markAppPprDynamicFallbackShellHtml,
rewriteAppPprFallbackShellHtmlNavigation,
} from "../packages/vinext/src/server/app-ppr-fallback-shell.js";
describe("createAppPprFallbackShell", () => {
it("builds a cacheComponents fallback shell from known root params and missing child params", () => {
const shell = createAppPprFallbackShell(
{
params: ["locale", "slug"],
pattern: "/:locale/blog/:slug",
rootParamNames: ["locale"],
},
{ locale: "en", slug: "new-post" },
);
expect(shell).toEqual({
fallbackParamNames: ["slug"],
pathname: "/en/blog/[slug]",
params: { locale: "en", slug: "[slug]" },
});
});
it("preserves catch-all placeholder shape in the shell path and params", () => {
const shell = createAppPprFallbackShell(
{
params: ["locale", "slug"],
pattern: "/:locale/docs/:slug+",
rootParamNames: ["locale"],
},
{ locale: "fr", slug: ["guides", "intro"] },
);
expect(shell).toEqual({
fallbackParamNames: ["slug"],
pathname: "/fr/docs/[...slug]",
params: { locale: "fr", slug: ["[...slug]"] },
});
});
it("preserves optional catch-all placeholder shape in the shell path and params", () => {
const shell = createAppPprFallbackShell(
{
params: ["locale", "slug"],
pattern: "/:locale/docs/:slug*",
rootParamNames: ["locale"],
},
{ locale: "fr", slug: ["guides", "intro"] },
);
expect(shell).toEqual({
fallbackParamNames: ["slug"],
pathname: "/fr/docs/[[...slug]]",
params: { locale: "fr", slug: ["[[...slug]]"] },
});
});
it("orders nested fallback shells from most specific child prefix to root-only", () => {
const shells = createAppPprFallbackShells(
{
params: ["locale", "category", "slug"],
pattern: "/:locale/category/:category/post/:slug",
rootParamNames: ["locale"],
},
{ locale: "en", category: "news", slug: "launch" },
);
expect(shells).toEqual([
{
fallbackParamNames: ["slug"],
pathname: "/en/category/news/post/[slug]",
params: { locale: "en", category: "news", slug: "[slug]" },
},
{
fallbackParamNames: ["category", "slug"],
pathname: "/en/category/[category]/post/[slug]",
params: { locale: "en", category: "[category]", slug: "[slug]" },
},
]);
});
it("does not create a fallback shell without a known root-param boundary", () => {
expect(
createAppPprFallbackShell(
{
params: ["locale", "slug"],
pattern: "/:locale/blog/:slug",
rootParamNames: [],
},
{ locale: "en", slug: "new-post" },
),
).toBeNull();
});
it("does not create a fallback shell when the matched request lacks a root param", () => {
expect(
createAppPprFallbackShell(
{
params: ["locale", "slug"],
pattern: "/:locale/blog/:slug",
rootParamNames: ["locale"],
},
{ slug: "new-post" },
),
).toBeNull();
});
});
describe("rewriteAppPprFallbackShellHtmlNavigation", () => {
it("patches cached fallback-shell HTML with the actual request navigation metadata", () => {
const html = rewriteAppPprFallbackShellHtmlNavigation({
html: "<html><head><title>x</title></head><body>shell</body></html>",
params: { locale: "en", slug: "new-post" },
pathname: "/en/blog/new-post",
searchParams: new URLSearchParams([["preview", "1"]]),
});
expect(html).toContain('params:{"locale":"en","slug":"new-post"}');
expect(html).toContain('"pathname":"/en/blog/new-post"');
expect(html).toContain('"searchParams":[["preview","1"]]');
const paramsIndex = html.indexOf('params:{"locale":"en","slug":"new-post"}');
const headCloseIndex = html.indexOf("</head>");
expect(paramsIndex).toBeGreaterThanOrEqual(0);
expect(headCloseIndex).toBeGreaterThanOrEqual(0);
expect(paramsIndex).toBeLessThan(headCloseIndex);
});
it("appends actual request metadata after cached placeholder metadata", () => {
const placeholderHtml = rewriteAppPprFallbackShellHtmlNavigation({
html: "<html><head><title>x</title></head><body>shell</body></html>",
params: { locale: "en", slug: "[slug]" },
pathname: "/en/blog/[slug]",
searchParams: new URLSearchParams(),
});
const html = rewriteAppPprFallbackShellHtmlNavigation({
html: placeholderHtml,
params: { locale: "en", slug: "new-post" },
pathname: "/en/blog/new-post",
searchParams: new URLSearchParams([["preview", "1"]]),
});
const placeholderIndex = html.indexOf('params:{"locale":"en","slug":"[slug]"}');
const actualIndex = html.indexOf('params:{"locale":"en","slug":"new-post"}');
const headCloseIndex = html.indexOf("</head>");
expect(placeholderIndex).toBeGreaterThanOrEqual(0);
expect(actualIndex).toBeGreaterThan(placeholderIndex);
expect(actualIndex).toBeLessThan(headCloseIndex);
expect(html).toContain('"pathname":"/en/blog/new-post"');
expect(html).toContain('"searchParams":[["preview","1"]]');
});
});
describe("dynamic fallback shell marker", () => {
it("persists the need for request-time resume in prerendered HTML", () => {
const html = markAppPprDynamicFallbackShellHtml("<html><body>fallback</body></html>");
expect(isAppPprDynamicFallbackShellHtml(html)).toBe(true);
expect(isAppPprDynamicFallbackShellHtml("<html><body>static</body></html>")).toBe(false);
});
});