Files
cloudflare__vinext/tests/thenable-params.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

351 lines
12 KiB
TypeScript

import { describe, expect, it } from "vite-plus/test";
import {
createPprFallbackShellState,
preparePprFallbackShellFinalRender,
runWithPprFallbackShellState,
} from "../packages/vinext/src/shims/ppr-fallback-shell.js";
import { makeThenableParams } from "../packages/vinext/src/shims/thenable-params.js";
describe("makeThenableParams", () => {
it("is awaitable even when params contain then", async () => {
// eslint-disable-next-line unicorn/no-thenable
const params = makeThenableParams({ then: "foo" });
// If `then` were shadowing Promise.prototype.then, `await` would try to
// treat the object as a thenable and call `then` with resolve/reject,
// producing garbage or hanging. This must resolve to the plain object.
const resolved = await params;
// eslint-disable-next-line unicorn/no-thenable
expect(resolved).toEqual({ then: "foo" });
});
it("allows sync access to non-well-known params", () => {
// eslint-disable-next-line unicorn/no-thenable
const params = makeThenableParams({ slug: "post", then: "foo" });
expect(params.slug).toBe("post");
});
it("protects Promise methods from shadowing so the object stays thenable", () => {
// eslint-disable-next-line unicorn/no-thenable
const params = makeThenableParams({ then: "foo", catch: "bar", finally: "baz" });
expect(typeof params.then).toBe("function");
expect(typeof params.catch).toBe("function");
expect(typeof params.finally).toBe("function");
});
it("protects React Promise status from shadowing", () => {
const params = makeThenableParams({ status: "ok" });
// React uses `status` on Promises for introspection; it must not be
// shadowed by a param of the same name. Use Reflect.get because the
// type system omits well-known properties from the sync intersection.
expect(Reflect.get(params, "status")).not.toBe("ok");
});
it("protects React Promise value and error from shadowing", () => {
const params = makeThenableParams({ value: "foo", error: "bar" });
// React may mutate resolved promises to attach `.value` and `.error`
// for `use()` caching. Our Proxy must not return the param value for
// these keys, or React would read the wrong value on re-render.
expect(Reflect.get(params, "value")).not.toBe("foo");
expect(Reflect.get(params, "error")).not.toBe("bar");
});
it("excludes well-known properties from enumeration", () => {
/* eslint-disable unicorn/no-thenable */
const params = makeThenableParams({
slug: "post",
then: "foo",
catch: "bar",
finally: "baz",
status: "ok",
value: "val",
error: "err",
toString: "str",
});
/* eslint-enable unicorn/no-thenable */
const keys = Object.keys(params);
expect(keys).toEqual(["slug"]);
});
it("makes well-known param values available after awaiting", async () => {
/* eslint-disable unicorn/no-thenable */
const params = makeThenableParams({
slug: "post",
then: "foo",
catch: "bar",
finally: "baz",
status: "ok",
value: "val",
error: "err",
});
/* eslint-enable unicorn/no-thenable */
const resolved = await params;
expect(resolved.slug).toBe("post");
expect(resolved.then).toBe("foo");
expect(resolved.catch).toBe("bar");
expect(resolved.finally).toBe("baz");
expect(resolved.status).toBe("ok");
expect(resolved.value).toBe("val");
expect(resolved.error).toBe("err");
});
it("preserves catch-all array params through sync and awaited access", async () => {
const params = makeThenableParams({ slug: ["a", "b"] });
expect(Reflect.get(params, "slug")).toEqual(["a", "b"]);
expect(Object.keys(params)).toEqual(["slug"]);
expect(await params).toEqual({ slug: ["a", "b"] });
});
it("preserves empty params through sync keys and awaiting", async () => {
const params = makeThenableParams({});
expect(Object.keys(params)).toEqual([]);
expect(await params).toEqual({});
});
it("reports direct param property access to an observer", () => {
const observedKeys: string[][] = [];
const params = makeThenableParams(
{ slug: "post" },
{
observeParamAccess(keys) {
observedKeys.push([...keys]);
},
},
);
expect(params.slug).toBe("post");
expect(observedKeys).toEqual([["slug"]]);
});
it("reports awaited params as an all-keys access", async () => {
const observedKeys: string[][] = [];
const params = makeThenableParams(
{ slug: "post", category: "news" },
{
observeParamAccess(keys) {
observedKeys.push([...keys]);
},
},
);
await params;
expect(observedKeys).toEqual([["slug", "category"]]);
});
it("reports destructured param property access to an observer", () => {
const observedKeys: string[][] = [];
const params = makeThenableParams(
{ slug: "post" },
{
observeParamAccess(keys) {
observedKeys.push([...keys]);
},
},
);
const { slug } = params;
expect(slug).toBe("post");
expect(observedKeys).toEqual([["slug"]]);
});
it("suspends only fallback params during cacheComponents fallback-shell prerendering", () => {
const state = createPprFallbackShellState({
fallbackParamNames: ["slug"],
routePattern: "/:locale/blog/:slug",
});
let thrown: unknown;
runWithPprFallbackShellState(state, () => {
const params = makeThenableParams({ locale: "en", slug: "[slug]" });
expect(params.locale).toBe("en");
expect(Object.keys(params)).toEqual(["locale", "slug"]);
try {
Reflect.get(params, "slug");
} catch (error) {
thrown = error;
}
});
state.abortController.abort();
expect(thrown).toBeDefined();
expect(typeof (thrown as Promise<unknown>).then).toBe("function");
});
it("awaited params object suspends on fallback param access", async () => {
const state = createPprFallbackShellState({
fallbackParamNames: ["slug"],
routePattern: "/:locale/blog/:slug",
});
await runWithPprFallbackShellState(state, async () => {
const params = makeThenableParams({ locale: "en", slug: "[slug]" });
const result = await params;
expect(result.locale).toBe("en");
expect(() => Reflect.get(result, "slug")).toThrow();
expect(state.hasDynamicBoundary).toBe(true);
});
state.abortController.abort();
});
it("awaiting params during fallback-shell prerender observes only accessed known params", async () => {
const observedKeys: string[][] = [];
const state = createPprFallbackShellState({
fallbackParamNames: ["slug"],
routePattern: "/:locale/blog/:slug",
});
await runWithPprFallbackShellState(state, async () => {
const params = makeThenableParams(
{ locale: "en", slug: "[slug]" },
{ observeParamAccess: (keys) => observedKeys.push([...keys]) },
);
const resolved = await params;
expect(resolved.locale).toBe("en");
// Known-param access must not convert the shell into a
// dynamic-boundary shell — only actual fallback-param access does.
expect(state.hasDynamicBoundary).toBe(false);
});
expect(observedKeys).toEqual([["locale"]]);
state.abortController.abort();
});
it("does not mark dynamic boundary until a fallback param is actually accessed", () => {
const state = createPprFallbackShellState({
fallbackParamNames: ["slug"],
routePattern: "/:locale/blog/:slug",
});
runWithPprFallbackShellState(state, () => {
const params = makeThenableParams({ locale: "en", slug: "[slug]" });
expect(params.locale).toBe("en");
expect(state.hasDynamicBoundary).toBe(false);
try {
Reflect.get(params, "slug");
} catch {
/* expected suspension */
}
expect(state.hasDynamicBoundary).toBe(true);
});
state.abortController.abort();
});
it("suspends on fallback param access even after the params object escapes the shell scope", () => {
const state = createPprFallbackShellState({
fallbackParamNames: ["slug"],
routePattern: "/:locale/blog/:slug",
});
// Build the params object inside the shell scope, then read the fallback
// key after the scope has exited. The object must still suspend against the
// shell that produced it instead of silently leaking the `[slug]`
// placeholder.
const params = runWithPprFallbackShellState(state, () =>
makeThenableParams({ locale: "en", slug: "[slug]" }),
);
expect(params.locale).toBe("en");
let thrownFromGet: unknown;
try {
Reflect.get(params, "slug");
} catch (error) {
thrownFromGet = error;
}
expect(thrownFromGet).toBeDefined();
expect(typeof (thrownFromGet as Promise<unknown>).then).toBe("function");
// The `getOwnPropertyDescriptor` trap must suspend identically — its getter
// throws the same hanging promise instead of falling through to `undefined`
// — so both traps agree on the out-of-scope path.
const descriptor = Object.getOwnPropertyDescriptor(params, "slug");
expect(typeof descriptor?.get).toBe("function");
let thrownFromDescriptor: unknown;
try {
descriptor?.get?.();
} catch (error) {
thrownFromDescriptor = error;
}
expect(thrownFromDescriptor).toBeDefined();
expect(typeof (thrownFromDescriptor as Promise<unknown>).then).toBe("function");
state.abortController.abort();
});
it("re-derives fallback suspension against the live controller after a phase transition", async () => {
const state = createPprFallbackShellState({
fallbackParamNames: ["slug"],
routePattern: "/:locale/blog/:slug",
});
const params = runWithPprFallbackShellState(state, () =>
makeThenableParams({ locale: "en", slug: "[slug]" }),
);
// Warmup access throws a hanging promise wired to the warmup controller.
let warmupPromise: Promise<unknown> | undefined;
try {
Reflect.get(params, "slug");
} catch (error) {
warmupPromise = error as Promise<unknown>;
}
expect(warmupPromise).toBeDefined();
const warmupController = state.abortController;
// Transition to the final render — this swaps in a fresh AbortController.
preparePprFallbackShellFinalRender(state);
expect(state.abortController).not.toBe(warmupController);
// Final-phase access must throw a *new* hanging promise wired to the live
// (final) controller, not the memoized warmup promise bound to the dead
// signal the lifecycle will never abort again.
let finalPromise: Promise<unknown> | undefined;
try {
Reflect.get(params, "slug");
} catch (error) {
finalPromise = error as Promise<unknown>;
}
expect(finalPromise).toBeDefined();
expect(finalPromise).not.toBe(warmupPromise);
// Aborting the live controller rejects the final-phase suspension.
const rejectedAssertion = expect(finalPromise).rejects.toThrow();
state.abortController.abort();
await rejectedAssertion;
// Settle the warmup promise too so it does not leak as unhandled.
warmupController.abort();
await (warmupPromise as Promise<unknown>).catch(() => {});
});
it("Object.keys(params) does not mark dynamic boundary before fallback param access", () => {
const state = createPprFallbackShellState({
fallbackParamNames: ["slug"],
routePattern: "/:locale/blog/:slug",
});
runWithPprFallbackShellState(state, () => {
const params = makeThenableParams({ locale: "en", slug: "[slug]" });
Object.keys(params);
expect(state.hasDynamicBoundary).toBe(false);
});
state.abortController.abort();
});
});