Files
cloudflare__vinext/tests/ppr-fallback-shell.test.ts
T
Nathan Nguyen 1c1744e0ea feat(ppr): add PPR fallback-shell render lifecycle tests (#1715)
* feat(ppr): add fallback shell render lifecycle

* fix(ppr): address review feedback on PR #1715

- Make await params resolvable during fallback-shell prerendering by keeping
the base promise as Promise.resolve(plain) and only throwing the hanging
promise on sync fallback-key access.

- Defer createPprFallbackShellSuspensePromise to first fallback-key access so
hasDynamicBoundary and cache-task ignoring only fire when a fallback param is
actually read, matching the suspend only on access contract.

- Store and clear pendingAbortCleanup in preparePprFallbackShellFinalRender
to avoid leaking uncancellable abort timers.

- Add tests for await params resolving known params and for lazy dynamic-boundary
marking when only non-fallback params are accessed.

* fix(ppr): make awaited params preserve fallback-key suspension and getOwnPropertyDescriptor fully lazy

- Promise now resolves to a Proxy (resolvedParams) instead of raw plain.
  The inner Proxy throws a hanging promise when a fallback param is accessed
  after await, so `const resolved = await params; resolved.slug` suspends.

- Both outer and inner Proxy getOwnPropertyDescriptor traps return a getter
  that creates the hanging promise lazily. Object.keys(params) no longer
  triggers side effects merely by enumerating keys.

- Added tests for awaited-params suspension and for enumeration not marking
  hasDynamicBoundary eagerly.

* fix(ppr): restructure thenable-params with typed Proxy handlers and conditional observer semantics

- Extract createResolvedParamsProxy with ProxyHandler<T> (no cast needed)
- Extract createThenableParamsProxy with single as ThenableParams<T> boundary cast
- Remove all as unknown as double casts
- In fallback-shell mode, Promise continuations no longer observe all keys;
  let the resolved params proxy report actual property access lazily
- Fix getOwnPropertyDescriptor to return throwing getter instead of
  throwing synchronously, preventing Object.keys from triggering suspension
- Restructure awaited-params test to wrap entire async flow in ALS scope
- Add test: awaiting params during fallback-shell observes only accessed known params

* test(ppr): assert hasDynamicBoundary stays false after known-param access in fallback-shell observer test

* fix(ppr): suspend escaped fallback params and settle warmup cache-ready without in-scope task

Addresses bonk review on #1715:

- Capture the originating fallback-shell state at params construction and
  create the suspense promise against it (via the new
  createPprFallbackShellSuspensePromiseForState), instead of re-reading the
  ALS store lazily. A params object that escapes the shell scope now still
  suspends on fallback-key access rather than leaking the raw `[slug]`
  placeholder, and the `get` / `getOwnPropertyDescriptor` traps no longer
  diverge on the out-of-scope path.
- During warmup, always call scheduleCacheReadyIfSettled when a dynamic
  boundary is hit (not only in the final phase), so a bare headers()/cookies()
  access with an empty cache-task stack still re-schedules the warmup
  cache-ready settle. The call is a no-op while cache tasks are pending.

Adds focused tests for both paths.

* fix(ppr): keep cache-task counter and fallback suspension correct across phase transition

Addresses follow-up bonk review on #1715:

- Add a per-state cache epoch, bumped on the warmup->final transition. Cache
  tasks capture the epoch at creation; a task that settles in a later epoch no
  longer decrements the freshly-reset pendingCacheTasks counter below zero
  (which would permanently block resolveCacheReadyIfSettled).
- Re-derive the memoized fallback-shell hanging promise when the live
  AbortController changes (preparePprFallbackShellFinalRender swaps it on the
  phase transition), so suspension always tracks the controller the lifecycle
  will actually abort instead of a stale warmup signal.

Adds tests for the late warmup-task settle and the post-transition re-derive.

---------

Co-authored-by: James Anderson <james@eli.cx>
2026-06-07 22:55:26 +01:00

288 lines
9.4 KiB
TypeScript

import { describe, expect, it } from "vite-plus/test";
import {
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.pendingCacheTasks).toBe(0);
expect(state.isAbortScheduled).toBe(false);
expect(state.cacheReadyResolvers.length).toBe(0);
expect(state.abortController.signal.aborted).toBe(false);
});
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 delay(5);
expect(isReady).toBe(true);
expect(state.pendingCacheTasks).toBe(0);
state.abortController.abort();
await ready.catch(() => {});
});
});