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>
308 lines
10 KiB
TypeScript
308 lines
10 KiB
TypeScript
import { describe, expect, it } from "vite-plus/test";
|
|
import {
|
|
beginPprFallbackShellFinalRender,
|
|
createPprFallbackShellState,
|
|
createPprFallbackShellSuspensePromise,
|
|
isPprFallbackShellAbortError,
|
|
preparePprFallbackShellFinalRender,
|
|
runWithPprFallbackShellState,
|
|
trackPprFallbackShellCacheTask,
|
|
waitForPprFallbackShellCacheReady,
|
|
} from "../packages/vinext/src/shims/ppr-fallback-shell.js";
|
|
|
|
function delay(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
describe("ppr fallback shell cache task tracking", () => {
|
|
it("waits for public cache work before marking warmup cache-ready", async () => {
|
|
const state = createPprFallbackShellState({
|
|
fallbackParamNames: ["slug"],
|
|
routePattern: "/:locale/blog/:slug",
|
|
});
|
|
let finishTask!: () => void;
|
|
let isReady = false;
|
|
|
|
const tracked = runWithPprFallbackShellState(state, () =>
|
|
trackPprFallbackShellCacheTask(
|
|
() => new Promise<void>((resolve) => (finishTask = resolve)),
|
|
"default",
|
|
),
|
|
);
|
|
const ready = waitForPprFallbackShellCacheReady(state).then(() => {
|
|
isReady = true;
|
|
});
|
|
|
|
await delay(5);
|
|
expect(isReady).toBe(false);
|
|
finishTask();
|
|
await tracked;
|
|
await ready;
|
|
expect(state.pendingCacheTasks).toBe(0);
|
|
});
|
|
|
|
it("completes independent child public cache work before cache-ready when parent hits dynamic boundary", async () => {
|
|
const state = createPprFallbackShellState({
|
|
fallbackParamNames: ["slug"],
|
|
routePattern: "/:locale/blog/:slug",
|
|
});
|
|
let childWorkResolve!: () => void;
|
|
const childWork = new Promise<void>((resolve) => {
|
|
childWorkResolve = resolve;
|
|
});
|
|
let childCompleted = false;
|
|
let isReady = false;
|
|
const readyPromise = waitForPprFallbackShellCacheReady(state).then(() => {
|
|
isReady = true;
|
|
});
|
|
|
|
runWithPprFallbackShellState(state, () =>
|
|
trackPprFallbackShellCacheTask(async () => {
|
|
trackPprFallbackShellCacheTask(async () => {
|
|
await childWork;
|
|
childCompleted = true;
|
|
}, "default").catch(() => {});
|
|
|
|
const suspension = createPprFallbackShellSuspensePromise("headers");
|
|
if (suspension) throw suspension;
|
|
}, "default"),
|
|
).catch(() => {});
|
|
|
|
await delay(5);
|
|
expect(isReady).toBe(false);
|
|
|
|
childWorkResolve();
|
|
await readyPromise;
|
|
|
|
expect(isReady).toBe(true);
|
|
expect(childCompleted).toBe(true);
|
|
expect(state.pendingCacheTasks).toBe(0);
|
|
|
|
state.abortController.abort();
|
|
});
|
|
|
|
it("stops waiting for cache tasks that suspend on fallback-shell dynamic work", async () => {
|
|
const state = createPprFallbackShellState({
|
|
fallbackParamNames: ["slug"],
|
|
routePattern: "/:locale/blog/:slug",
|
|
});
|
|
let reachedAfterSuspend = false;
|
|
|
|
const tracked = runWithPprFallbackShellState(state, () =>
|
|
trackPprFallbackShellCacheTask(
|
|
() =>
|
|
trackPprFallbackShellCacheTask(async () => {
|
|
const suspension = createPprFallbackShellSuspensePromise<void>("`params`");
|
|
if (suspension) {
|
|
await suspension;
|
|
}
|
|
reachedAfterSuspend = true;
|
|
}, "default"),
|
|
"default",
|
|
),
|
|
);
|
|
|
|
await waitForPprFallbackShellCacheReady(state);
|
|
expect(state.pendingCacheTasks).toBe(0);
|
|
expect(reachedAfterSuspend).toBe(false);
|
|
|
|
state.abortController.abort();
|
|
await tracked.catch(() => undefined);
|
|
});
|
|
});
|
|
|
|
describe("ppr fallback shell render lifecycle", () => {
|
|
it("createPprFallbackShellSuspensePromise returns a promise for params expression", () => {
|
|
const state = createPprFallbackShellState({
|
|
fallbackParamNames: ["slug"],
|
|
routePattern: "/:locale/blog/:slug",
|
|
});
|
|
|
|
runWithPprFallbackShellState(state, () => {
|
|
const promise = createPprFallbackShellSuspensePromise("params");
|
|
expect(promise).not.toBeNull();
|
|
expect(typeof (promise as Promise<void>)?.then).toBe("function");
|
|
expect(state.hasDynamicBoundary).toBe(true);
|
|
});
|
|
|
|
state.abortController.abort();
|
|
});
|
|
|
|
it("createPprFallbackShellSuspensePromise returns a promise for headers expression", () => {
|
|
const state = createPprFallbackShellState({
|
|
fallbackParamNames: ["slug"],
|
|
routePattern: "/:locale/blog/:slug",
|
|
});
|
|
|
|
runWithPprFallbackShellState(state, () => {
|
|
const promise = createPprFallbackShellSuspensePromise("headers");
|
|
expect(promise).not.toBeNull();
|
|
expect(state.hasDynamicBoundary).toBe(true);
|
|
});
|
|
|
|
state.abortController.abort();
|
|
});
|
|
|
|
it("createPprFallbackShellSuspensePromise returns a promise for cookies expression", () => {
|
|
const state = createPprFallbackShellState({
|
|
fallbackParamNames: ["slug"],
|
|
routePattern: "/:locale/blog/:slug",
|
|
});
|
|
|
|
runWithPprFallbackShellState(state, () => {
|
|
const promise = createPprFallbackShellSuspensePromise("cookies");
|
|
expect(promise).not.toBeNull();
|
|
expect(state.hasDynamicBoundary).toBe(true);
|
|
});
|
|
|
|
state.abortController.abort();
|
|
});
|
|
|
|
it("createPprFallbackShellSuspensePromise returns null outside shell context", () => {
|
|
const promise = createPprFallbackShellSuspensePromise("params");
|
|
expect(promise).toBeNull();
|
|
});
|
|
|
|
it("waitForPprFallbackShellCacheReady resolves immediately in final phase", async () => {
|
|
const state = createPprFallbackShellState({
|
|
fallbackParamNames: ["slug"],
|
|
routePattern: "/:locale/blog/:slug",
|
|
});
|
|
|
|
preparePprFallbackShellFinalRender(state);
|
|
expect(state.phase).toBe("final");
|
|
|
|
const result = await waitForPprFallbackShellCacheReady(state);
|
|
expect(result).toBeUndefined();
|
|
});
|
|
|
|
it("preparePprFallbackShellFinalRender resets state for final render", () => {
|
|
const state = createPprFallbackShellState({
|
|
fallbackParamNames: ["slug"],
|
|
routePattern: "/:locale/blog/:slug",
|
|
});
|
|
|
|
state.hasDynamicBoundary = true;
|
|
state.pendingCacheTasks = 3;
|
|
|
|
preparePprFallbackShellFinalRender(state);
|
|
|
|
expect(state.phase).toBe("final");
|
|
expect(state.hasDynamicBoundary).toBe(false);
|
|
expect(state.isFinalRenderStarted).toBe(false);
|
|
expect(state.pendingCacheTasks).toBe(0);
|
|
expect(state.isAbortScheduled).toBe(false);
|
|
expect(state.cacheReadyResolvers.length).toBe(0);
|
|
expect(state.abortController.signal.aborted).toBe(false);
|
|
});
|
|
|
|
it("does not abort the final shell before the React prerender starts", async () => {
|
|
const state = createPprFallbackShellState({
|
|
fallbackParamNames: ["slug"],
|
|
routePattern: "/:locale/blog/:slug",
|
|
});
|
|
preparePprFallbackShellFinalRender(state);
|
|
|
|
runWithPprFallbackShellState(state, () => {
|
|
void createPprFallbackShellSuspensePromise("params");
|
|
});
|
|
await delay(5);
|
|
expect(state.reactAbortController.signal.aborted).toBe(false);
|
|
|
|
beginPprFallbackShellFinalRender(state);
|
|
await delay(5);
|
|
expect(state.reactAbortController.signal.aborted).toBe(true);
|
|
expect(state.abortController.signal.aborted).toBe(true);
|
|
});
|
|
|
|
it("isPprFallbackShellAbortError returns true for DOMException AbortError", () => {
|
|
const error = new DOMException("aborted", "AbortError");
|
|
expect(isPprFallbackShellAbortError(error)).toBe(true);
|
|
});
|
|
|
|
it("isPprFallbackShellAbortError returns false for regular errors", () => {
|
|
expect(isPprFallbackShellAbortError(new Error("something else"))).toBe(false);
|
|
expect(isPprFallbackShellAbortError("string error")).toBe(false);
|
|
expect(isPprFallbackShellAbortError(null)).toBe(false);
|
|
});
|
|
|
|
it("re-schedules warmup cache-ready when a dynamic boundary has no in-scope cache task", () => {
|
|
const state = createPprFallbackShellState({
|
|
fallbackParamNames: ["slug"],
|
|
routePattern: "/:locale/blog/:slug",
|
|
});
|
|
|
|
expect(state.pendingCacheReadyCleanup).toBeNull();
|
|
|
|
// A bare `headers()`/`cookies()` access outside any tracked cache task has
|
|
// an empty cache-task stack, so `ignoreCacheTask` completes nothing and
|
|
// cannot drive the settle. The suspense creation itself must re-schedule
|
|
// the warmup cache-ready settle; previously this only happened in the
|
|
// final phase, leaving a warmup waiter un-settled.
|
|
runWithPprFallbackShellState(state, () => {
|
|
void createPprFallbackShellSuspensePromise("headers");
|
|
});
|
|
|
|
expect(state.pendingCacheReadyCleanup).not.toBeNull();
|
|
|
|
state.abortController.abort();
|
|
});
|
|
|
|
it("does not drive pendingCacheTasks negative when a warmup task settles after final transition", async () => {
|
|
const state = createPprFallbackShellState({
|
|
fallbackParamNames: ["slug"],
|
|
routePattern: "/:locale/blog/:slug",
|
|
});
|
|
|
|
let finishWarmupTask!: () => void;
|
|
const tracked = runWithPprFallbackShellState(state, () =>
|
|
trackPprFallbackShellCacheTask(
|
|
() => new Promise<void>((resolve) => (finishWarmupTask = resolve)),
|
|
"default",
|
|
),
|
|
);
|
|
expect(state.pendingCacheTasks).toBe(1);
|
|
|
|
// Transition to the final render while the warmup task is still in flight.
|
|
// This resets `pendingCacheTasks` to 0.
|
|
preparePprFallbackShellFinalRender(state);
|
|
expect(state.pendingCacheTasks).toBe(0);
|
|
|
|
// The stale warmup task settling must not decrement the reset counter
|
|
// below zero (which would permanently block `waitForPprFallbackShellCacheReady`).
|
|
finishWarmupTask();
|
|
await tracked;
|
|
expect(state.pendingCacheTasks).toBe(0);
|
|
|
|
// Final-phase cache-ready still resolves immediately.
|
|
await waitForPprFallbackShellCacheReady(state);
|
|
|
|
state.abortController.abort();
|
|
});
|
|
|
|
it("multiple suspense promises in the same warmup phase track correctly", async () => {
|
|
const state = createPprFallbackShellState({
|
|
fallbackParamNames: ["slug"],
|
|
routePattern: "/:locale/blog/:slug",
|
|
});
|
|
let isReady = false;
|
|
|
|
const ready = waitForPprFallbackShellCacheReady(state).then(() => {
|
|
isReady = true;
|
|
});
|
|
|
|
runWithPprFallbackShellState(state, () => {
|
|
const p1 = createPprFallbackShellSuspensePromise("params");
|
|
expect(p1).not.toBeNull();
|
|
const p2 = createPprFallbackShellSuspensePromise("headers");
|
|
expect(p2).not.toBeNull();
|
|
});
|
|
|
|
await ready;
|
|
expect(isReady).toBe(true);
|
|
expect(state.pendingCacheTasks).toBe(0);
|
|
|
|
state.abortController.abort();
|
|
});
|
|
});
|