mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
1c1744e0ea
* 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>
351 lines
12 KiB
TypeScript
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();
|
|
});
|
|
});
|