mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
f5bee1a26f
* fix(app-router): preserve unstable_catchError boundaries
* test(e2e): add Next-parity test for shouldRerenderAppPageWithGlobalError (#1906)
Add a test proving that for uncaught post-shell RSC errors (with no
client boundary), the server preserves the original HTML stream and the
client renders global-error. This is the Next.js behavior that justifies
keeping shouldRerenderAppPageWithGlobalError unconditional at false.
The server cannot detect userland client boundaries like
unstable_catchError from the route manifest, so the only safe way to
get a global-error rerender is to make a targeted error-boundary test.
The test uses a loading.tsx boundary so the error is truly post-shell,
not caught by the pre-shell error-recovery catch block.
* fix(error): restore sentinel wrapper for falsy thrown values in unstable_catchError
Restores the _CatchErrorState sentinel wrapper ({ thrownValue: unknown }) so
falsy thrown values (null, undefined, 0, "") are routed to the fallback
instead of being treated as no error. Also restores ErrorInfo.error as unknown.
Adds back unit tests for falsy values, class-component lifecycle, and
unstable_retry behavior parity with the component-rendered fallback shape.
Refs: #1906
* fix(error): align ambient ErrorInfo.error type with runtime implementation
* refactor(app-router): remove shouldRerenderAppPageWithGlobalError dead code
The function always returned false and its _options parameter and
ShouldRerenderAppPageWithGlobalErrorOptions type were vestigial.
The call site at app-page-render.ts:892-904 was therefore never taken.
Remove the function, type, import, call site, and the now-redundant
unit test, leaving the shell-level recovery path in
renderAppPageHtmlStreamWithRecovery as the sole error recovery seam.
Addresses ask-bonk review comment on #1906.
* fix(test): restore typed globalAny view and document Fallback as any intent
Addresses two non-blocking review findings from ask-bonk on PR #1906:
- Restored the typed `globalThis as unknown as { window?: unknown }`
pattern for window stubs in the unstable_catchError test section,
replacing the `(globalThis as any).window` pattern that was a type-safety
regression from the prior rewrite.
- Added an inline comment explaining why `Fallback as any` is necessary
in the class-component lifecycle test: the test probes the inner
_CatchError class directly, where the fallback function is used as a
React component type (receiving a single `{ props, errorInfo }` object)
rather than the public API signature `(props, errorInfo)`.
* chore: trigger CI
* fix(test): rename forwardedProps to props in unstable-catchError test
Addresses ask-bonk review finding on PR #1906: the test was seeding
the inner _CatchError class with the stale key name forwardedProps,
but the production wrapper now passes props (and isPagesRouter).
Since this test only exercises the App Router unstable_retry path
(which reads neither key), behavior was unaffected, but renaming
aligns the test with the current boundary shape for future readers.
* refactor(server): remove dead routeHasLocalBoundary field
Removing shouldRerenderAppPageWithGlobalError (commit abb416b)
deleted its only reader of routeHasLocalBoundary. The field is still
declared in the options type and populated on every render, but
nothing consumes it anymore. Removes:
- routeHasLocalBoundary from AppPageRenderOptions type
- The computation in app-page-dispatch.ts
- Two test stubs in app-page-render.test.ts
Addresses ask-bonk review finding on PR #1906.
818 lines
27 KiB
TypeScript
818 lines
27 KiB
TypeScript
import React from "react";
|
|
import { describe, expect, it, vi } from "vite-plus/test";
|
|
import {
|
|
buildAppPageProbes,
|
|
probeAppPage,
|
|
probeAppPageBeforeRender,
|
|
probeReactServerSubtree,
|
|
} from "../packages/vinext/src/server/app-page-probe.js";
|
|
import {
|
|
consumeDynamicUsage,
|
|
consumeRenderRequestApiUsage,
|
|
} from "../packages/vinext/src/shims/headers.js";
|
|
|
|
// Mirrors makeThenableParams() from app-rsc-entry.ts — the function that
|
|
// converts raw null-prototype params into objects that work with both
|
|
// `await params` (Next.js 15+) and `params.id` (pre-15).
|
|
function makeThenableParams<T extends Record<string, unknown>>(obj: T): Promise<T> & T {
|
|
const plain = { ...obj } as T;
|
|
return Object.assign(Promise.resolve(plain), plain);
|
|
}
|
|
|
|
async function registerCachedProbePage<TProps extends Record<string, unknown>, TResult>(
|
|
fn: (props: TProps) => Promise<TResult>,
|
|
id: string,
|
|
): Promise<(props: TProps) => Promise<TResult>> {
|
|
const { registerCachedFunction } = await import("../packages/vinext/src/shims/cache-runtime.js");
|
|
const { MemoryCacheHandler, setCacheHandler } =
|
|
await import("../packages/vinext/src/shims/cache.js");
|
|
setCacheHandler(new MemoryCacheHandler());
|
|
consumeDynamicUsage();
|
|
consumeRenderRequestApiUsage();
|
|
return registerCachedFunction(fn, id);
|
|
}
|
|
|
|
describe("app page probe helpers", () => {
|
|
it("probes server components returned below a layout result", async () => {
|
|
const calls: string[] = [];
|
|
|
|
function Child() {
|
|
calls.push("child");
|
|
return null;
|
|
}
|
|
|
|
function Layout() {
|
|
calls.push("layout");
|
|
return React.createElement("section", null, React.createElement(Child));
|
|
}
|
|
|
|
await probeReactServerSubtree(React.createElement(Layout));
|
|
|
|
expect(calls).toEqual(["layout", "child"]);
|
|
});
|
|
|
|
it("probes memo and forwardRef server components returned below a layout result", async () => {
|
|
const calls: string[] = [];
|
|
|
|
const MemoChild = React.memo(function MemoChild() {
|
|
calls.push("memo");
|
|
return null;
|
|
});
|
|
const ForwardRefChild = React.forwardRef(function ForwardRefChild() {
|
|
calls.push("forwardRef");
|
|
return null;
|
|
});
|
|
const MemoForwardRefChild = React.memo(
|
|
React.forwardRef(function MemoForwardRefChild() {
|
|
calls.push("memoForwardRef");
|
|
return null;
|
|
}),
|
|
);
|
|
|
|
function Layout() {
|
|
calls.push("layout");
|
|
return React.createElement(
|
|
"section",
|
|
null,
|
|
React.createElement(MemoChild),
|
|
React.createElement(ForwardRefChild),
|
|
React.createElement(MemoForwardRefChild),
|
|
);
|
|
}
|
|
|
|
await probeReactServerSubtree(React.createElement(Layout));
|
|
|
|
expect(calls).toEqual(["layout", "memo", "forwardRef", "memoForwardRef"]);
|
|
});
|
|
|
|
it("probes lazy server components returned below a layout result", async () => {
|
|
const calls: string[] = [];
|
|
|
|
const LazyChild = React.lazy(() =>
|
|
Promise.resolve({
|
|
default() {
|
|
calls.push("lazy");
|
|
return null;
|
|
},
|
|
}),
|
|
);
|
|
|
|
function Layout() {
|
|
calls.push("layout");
|
|
return React.createElement("section", null, React.createElement(LazyChild));
|
|
}
|
|
|
|
await probeReactServerSubtree(React.createElement(Layout));
|
|
|
|
expect(calls).toEqual(["layout", "lazy"]);
|
|
});
|
|
|
|
it("enforces subtree depth limits for nested arrays", async () => {
|
|
await expect(
|
|
probeReactServerSubtree([[[React.createElement("span")]]], { maxDepth: 1 }),
|
|
).rejects.toThrow("App page layout subtree probe exceeded max depth");
|
|
});
|
|
|
|
it("enforces subtree node limits for large arrays", async () => {
|
|
await expect(probeReactServerSubtree([1, 2, 3], { maxNodes: 2 })).rejects.toThrow(
|
|
"App page layout subtree probe exceeded max nodes",
|
|
);
|
|
});
|
|
|
|
it("does not consume single-use iterables while probing layout children", async () => {
|
|
function Child() {
|
|
return null;
|
|
}
|
|
|
|
function* createChildren() {
|
|
yield React.createElement(Child);
|
|
}
|
|
|
|
const sharedChildren = createChildren();
|
|
|
|
function Layout() {
|
|
return React.createElement("section", null, sharedChildren);
|
|
}
|
|
|
|
await expect(probeReactServerSubtree(React.createElement(Layout))).rejects.toThrow(
|
|
"App page layout subtree probe cannot safely inspect iterable children",
|
|
);
|
|
expect(sharedChildren.next().value).toMatchObject({ type: Child });
|
|
});
|
|
|
|
it("handles layout special errors before probing the page", async () => {
|
|
const layoutError = new Error("layout failed");
|
|
const pageProbe = vi.fn(() => "page");
|
|
const renderLayoutSpecialError = vi.fn(
|
|
async () => new Response("layout-fallback", { status: 404 }),
|
|
);
|
|
const renderPageSpecialError = vi.fn();
|
|
const probedLayouts: number[] = [];
|
|
|
|
const result = await probeAppPageBeforeRender({
|
|
hasLoadingBoundary: false,
|
|
layoutCount: 3,
|
|
probeLayoutAt(layoutIndex) {
|
|
probedLayouts.push(layoutIndex);
|
|
if (layoutIndex === 1) {
|
|
throw layoutError;
|
|
}
|
|
return null;
|
|
},
|
|
probePage: pageProbe,
|
|
renderLayoutSpecialError,
|
|
renderPageSpecialError,
|
|
resolveSpecialError(error) {
|
|
return error === layoutError
|
|
? {
|
|
kind: "http-access-fallback",
|
|
statusCode: 404,
|
|
}
|
|
: null;
|
|
},
|
|
runWithSuppressedHookWarning(probe) {
|
|
return probe();
|
|
},
|
|
});
|
|
|
|
expect(probedLayouts).toEqual([2, 1]);
|
|
expect(pageProbe).not.toHaveBeenCalled();
|
|
expect(renderLayoutSpecialError).toHaveBeenCalledWith(
|
|
{
|
|
kind: "http-access-fallback",
|
|
statusCode: 404,
|
|
},
|
|
1,
|
|
);
|
|
expect(renderPageSpecialError).not.toHaveBeenCalled();
|
|
expect(result.response?.status).toBe(404);
|
|
await expect(result.response?.text()).resolves.toBe("layout-fallback");
|
|
});
|
|
|
|
it("falls through to the page probe when layout failures are not special", async () => {
|
|
const layoutError = new Error("ordinary layout failure");
|
|
const pageProbe = vi.fn(() => null);
|
|
const renderLayoutSpecialError = vi.fn();
|
|
|
|
const result = await probeAppPageBeforeRender({
|
|
hasLoadingBoundary: false,
|
|
layoutCount: 2,
|
|
probeLayoutAt(layoutIndex) {
|
|
if (layoutIndex === 1) {
|
|
throw layoutError;
|
|
}
|
|
return null;
|
|
},
|
|
probePage: pageProbe,
|
|
renderLayoutSpecialError,
|
|
renderPageSpecialError() {
|
|
throw new Error("should not render a page special error");
|
|
},
|
|
resolveSpecialError() {
|
|
return null;
|
|
},
|
|
runWithSuppressedHookWarning(probe) {
|
|
return probe();
|
|
},
|
|
});
|
|
|
|
expect(result.response).toBeNull();
|
|
expect(pageProbe).toHaveBeenCalledTimes(1);
|
|
expect(renderLayoutSpecialError).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("turns special page probe failures into immediate responses", async () => {
|
|
const pageError = new Error("page failed");
|
|
const renderPageSpecialError = vi.fn(
|
|
async () => new Response("page-fallback", { status: 307 }),
|
|
);
|
|
|
|
const result = await probeAppPageBeforeRender({
|
|
hasLoadingBoundary: false,
|
|
layoutCount: 0,
|
|
probeLayoutAt() {
|
|
throw new Error("should not probe layouts");
|
|
},
|
|
probePage() {
|
|
return Promise.reject(pageError);
|
|
},
|
|
renderLayoutSpecialError() {
|
|
throw new Error("should not render a layout special error");
|
|
},
|
|
renderPageSpecialError,
|
|
resolveSpecialError(error) {
|
|
return error === pageError
|
|
? {
|
|
kind: "redirect",
|
|
location: "/target",
|
|
statusCode: 307,
|
|
}
|
|
: null;
|
|
},
|
|
runWithSuppressedHookWarning(probe) {
|
|
return probe();
|
|
},
|
|
});
|
|
|
|
expect(renderPageSpecialError).toHaveBeenCalledWith({
|
|
kind: "redirect",
|
|
location: "/target",
|
|
statusCode: 307,
|
|
});
|
|
expect(result.response?.status).toBe(307);
|
|
await expect(result.response?.text()).resolves.toBe("page-fallback");
|
|
});
|
|
|
|
it("propagates layoutFlags from layout probe result", async () => {
|
|
const pageProbe = vi.fn(() => null);
|
|
|
|
const result = await probeAppPageBeforeRender({
|
|
hasLoadingBoundary: false,
|
|
layoutCount: 2,
|
|
probeLayoutAt() {
|
|
return null;
|
|
},
|
|
probePage: pageProbe,
|
|
renderLayoutSpecialError() {
|
|
throw new Error("should not render a layout special error");
|
|
},
|
|
renderPageSpecialError() {
|
|
throw new Error("should not render a page special error");
|
|
},
|
|
resolveSpecialError() {
|
|
return null;
|
|
},
|
|
runWithSuppressedHookWarning(probe) {
|
|
return probe();
|
|
},
|
|
classification: {
|
|
buildTimeClassifications: new Map([
|
|
[0, "static"],
|
|
[1, "dynamic"],
|
|
]),
|
|
getLayoutId(layoutIndex) {
|
|
return ["layout:/", "layout:/admin"][layoutIndex];
|
|
},
|
|
async runWithIsolatedDynamicScope(fn) {
|
|
return { result: await fn(), dynamicDetected: false };
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(result.response).toBeNull();
|
|
expect(result.layoutFlags).toEqual({
|
|
"layout:/": "s",
|
|
"layout:/admin": "d",
|
|
});
|
|
});
|
|
|
|
it("still handles special errors with classification enabled", async () => {
|
|
const layoutError = new Error("layout failed");
|
|
|
|
const result = await probeAppPageBeforeRender({
|
|
hasLoadingBoundary: false,
|
|
layoutCount: 2,
|
|
probeLayoutAt(layoutIndex) {
|
|
if (layoutIndex === 1) {
|
|
throw layoutError;
|
|
}
|
|
return null;
|
|
},
|
|
probePage() {
|
|
throw new Error("should not probe page");
|
|
},
|
|
renderLayoutSpecialError: vi.fn(async () => new Response("layout-fallback", { status: 404 })),
|
|
renderPageSpecialError() {
|
|
throw new Error("should not render a page special error");
|
|
},
|
|
resolveSpecialError(error) {
|
|
return error === layoutError ? { kind: "http-access-fallback", statusCode: 404 } : null;
|
|
},
|
|
runWithSuppressedHookWarning(probe) {
|
|
return probe();
|
|
},
|
|
classification: {
|
|
getLayoutId(layoutIndex) {
|
|
return ["layout:/", "layout:/admin"][layoutIndex];
|
|
},
|
|
async runWithIsolatedDynamicScope(fn) {
|
|
return { result: await fn(), dynamicDetected: false };
|
|
},
|
|
},
|
|
});
|
|
|
|
// Special error response should still be returned
|
|
expect(result.response?.status).toBe(404);
|
|
});
|
|
|
|
// ── Regression: probePage must receive thenable params/searchParams ──
|
|
// probePage() in the generated entry was passing raw null-prototype params
|
|
// (from trieMatch) instead of thenable params. Pages using `await params`
|
|
// (Next.js 15+ pattern) threw TypeError during probe, causing the probe to
|
|
// silently swallow the error instead of detecting notFound()/redirect().
|
|
|
|
it("detects notFound() from an async-params page when params are thenable", async () => {
|
|
const NOT_FOUND_ERROR = new Error("NEXT_NOT_FOUND");
|
|
const params = Object.create(null);
|
|
params.id = "invalid";
|
|
|
|
// Simulates a page that does `const { id } = await params; notFound()`
|
|
async function AsyncParamsPage(props: { params: Promise<{ id: string }> }) {
|
|
const { id } = await props.params;
|
|
if (id === "invalid") throw NOT_FOUND_ERROR;
|
|
return null;
|
|
}
|
|
|
|
const renderPageSpecialError = vi.fn(
|
|
async () => new Response("not-found-fallback", { status: 404 }),
|
|
);
|
|
|
|
// With thenable params, the probe should catch notFound()
|
|
const result = await probeAppPageBeforeRender({
|
|
hasLoadingBoundary: false,
|
|
layoutCount: 0,
|
|
probeLayoutAt() {
|
|
return null;
|
|
},
|
|
probePage() {
|
|
return AsyncParamsPage({ params: makeThenableParams(params) });
|
|
},
|
|
renderLayoutSpecialError() {
|
|
throw new Error("unreachable");
|
|
},
|
|
renderPageSpecialError,
|
|
resolveSpecialError(error) {
|
|
return error === NOT_FOUND_ERROR ? { kind: "http-access-fallback", statusCode: 404 } : null;
|
|
},
|
|
runWithSuppressedHookWarning(probe) {
|
|
return probe();
|
|
},
|
|
});
|
|
|
|
expect(renderPageSpecialError).toHaveBeenCalledOnce();
|
|
expect(result.response?.status).toBe(404);
|
|
});
|
|
|
|
it("detects redirect() from an async-searchParams page when searchParams are thenable", async () => {
|
|
const REDIRECT_ERROR = new Error("NEXT_REDIRECT");
|
|
|
|
// Simulates a page that does `const { dest } = await searchParams; redirect(dest)`
|
|
async function AsyncSearchPage(props: {
|
|
params: Promise<Record<string, unknown>>;
|
|
searchParams: Promise<{ dest?: string }>;
|
|
}) {
|
|
const { dest } = await props.searchParams;
|
|
if (dest) throw REDIRECT_ERROR;
|
|
return null;
|
|
}
|
|
|
|
const renderPageSpecialError = vi.fn(
|
|
async () => new Response(null, { status: 307, headers: { location: "/about" } }),
|
|
);
|
|
|
|
const result = await probeAppPageBeforeRender({
|
|
hasLoadingBoundary: false,
|
|
layoutCount: 0,
|
|
probeLayoutAt() {
|
|
return null;
|
|
},
|
|
probePage() {
|
|
return AsyncSearchPage({
|
|
params: makeThenableParams({}),
|
|
searchParams: makeThenableParams({ dest: "/about" }),
|
|
});
|
|
},
|
|
renderLayoutSpecialError() {
|
|
throw new Error("unreachable");
|
|
},
|
|
renderPageSpecialError,
|
|
resolveSpecialError(error) {
|
|
return error === REDIRECT_ERROR
|
|
? { kind: "redirect", location: "/about", statusCode: 307 }
|
|
: null;
|
|
},
|
|
runWithSuppressedHookWarning(probe) {
|
|
return probe();
|
|
},
|
|
});
|
|
|
|
expect(renderPageSpecialError).toHaveBeenCalledOnce();
|
|
expect(result.response?.status).toBe(307);
|
|
});
|
|
|
|
it("probe silently fails when searchParams is omitted and page awaits it", async () => {
|
|
const REDIRECT_ERROR = new Error("NEXT_REDIRECT");
|
|
|
|
// When the old probePage() omitted searchParams, the component received
|
|
// undefined for that prop. `await undefined` produces undefined, then
|
|
// destructuring undefined throws TypeError. The probe catches it but
|
|
// doesn't recognize it as a special error, so it returns null.
|
|
const renderPageSpecialError = vi.fn(async () => new Response(null, { status: 307 }));
|
|
|
|
const result = await probeAppPageBeforeRender({
|
|
hasLoadingBoundary: false,
|
|
layoutCount: 0,
|
|
probeLayoutAt() {
|
|
return null;
|
|
},
|
|
probePage() {
|
|
// Simulate what happens at runtime when searchParams is not passed:
|
|
// the page component receives no searchParams prop, then tries to
|
|
// destructure it after await. This throws TypeError.
|
|
return Promise.resolve().then(() => {
|
|
throw new TypeError("Cannot destructure property 'dest' of undefined");
|
|
});
|
|
},
|
|
renderLayoutSpecialError() {
|
|
throw new Error("unreachable");
|
|
},
|
|
renderPageSpecialError,
|
|
resolveSpecialError(error) {
|
|
return error === REDIRECT_ERROR
|
|
? { kind: "redirect", location: "/about", statusCode: 307 }
|
|
: null;
|
|
},
|
|
runWithSuppressedHookWarning(probe) {
|
|
return probe();
|
|
},
|
|
});
|
|
|
|
// The probe catches the TypeError but resolveSpecialError returns null
|
|
// for it (TypeError is not a special error) so the probe returns null.
|
|
// The redirect is never detected early.
|
|
expect(result.response).toBeNull();
|
|
expect(renderPageSpecialError).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("skips the page probe when a loading boundary is present (special errors handled post-shell)", async () => {
|
|
// With a route-level loading.tsx Suspense boundary, the probe can't
|
|
// catch a redirect()/notFound() thrown by the page without serializing
|
|
// on the page promise — which would defeat loading.tsx's whole point.
|
|
// Recovery instead happens later in renderAppPageLifecycle: the
|
|
// rscErrorTracker captures the digest from React's onError, and a short
|
|
// race window after shell-ready swaps the response to a 307/404 before
|
|
// bytes are flushed.
|
|
const probePage = vi.fn(() => new Promise<void>(() => {}));
|
|
const renderPageSpecialError = vi.fn();
|
|
|
|
const result = await probeAppPageBeforeRender({
|
|
hasLoadingBoundary: true,
|
|
layoutCount: 0,
|
|
probeLayoutAt() {
|
|
throw new Error("should not probe layouts");
|
|
},
|
|
probePage,
|
|
renderLayoutSpecialError() {
|
|
throw new Error("should not render a layout special error");
|
|
},
|
|
renderPageSpecialError,
|
|
resolveSpecialError() {
|
|
throw new Error("should not be reached when the page probe is skipped");
|
|
},
|
|
runWithSuppressedHookWarning(probe) {
|
|
return probe();
|
|
},
|
|
});
|
|
|
|
expect(probePage).not.toHaveBeenCalled();
|
|
expect(renderPageSpecialError).not.toHaveBeenCalled();
|
|
expect(result.response).toBeNull();
|
|
});
|
|
});
|
|
|
|
// Regression coverage for https://github.com/cloudflare/vinext/issues/1235.
|
|
//
|
|
// The generated RSC entry originally hand-rolled the probePage() body and read
|
|
// a non-existent key off collectAppPageSearchParams's return value, so the
|
|
// page component received `undefined` for searchParams and any
|
|
// `await searchParams` threw TypeError during probing. probeAppPage()
|
|
// encapsulates that wiring so the entry can delegate to a single typed call
|
|
// and the behaviour is unit-testable in isolation.
|
|
describe("probeAppPage", () => {
|
|
it("invokes the page with thenable params and resolved searchParams", async () => {
|
|
const calls: { params: unknown; searchParams: unknown }[] = [];
|
|
function Page(props: {
|
|
params: Promise<Record<string, string>>;
|
|
searchParams: Promise<Record<string, string | string[]>>;
|
|
}) {
|
|
calls.push({ params: props.params, searchParams: props.searchParams });
|
|
return "rendered";
|
|
}
|
|
|
|
const asyncRouteParams = makeThenableParams({ slug: "intro" });
|
|
const result = probeAppPage({
|
|
pageComponent: Page,
|
|
asyncRouteParams,
|
|
searchParams: new URLSearchParams("id=abc&tag=hello&tag=world"),
|
|
});
|
|
|
|
expect(result).toBe("rendered");
|
|
expect(calls).toHaveLength(1);
|
|
expect(calls[0]?.params).toBe(asyncRouteParams);
|
|
|
|
const sp = (await calls[0]?.searchParams) as Record<string, string | string[]>;
|
|
expect(sp.id).toBe("abc");
|
|
expect(sp.tag).toEqual(["hello", "world"]);
|
|
});
|
|
|
|
it("returns null when the page has no default export to render", () => {
|
|
expect(
|
|
probeAppPage({
|
|
pageComponent: undefined,
|
|
asyncRouteParams: makeThenableParams({}),
|
|
searchParams: new URLSearchParams("id=abc"),
|
|
}),
|
|
).toBeNull();
|
|
expect(
|
|
probeAppPage({
|
|
pageComponent: null,
|
|
asyncRouteParams: makeThenableParams({}),
|
|
searchParams: null,
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("passes an empty searchParams object when the request has no query string", async () => {
|
|
let received: Record<string, unknown> | undefined;
|
|
async function Page(props: { searchParams: Promise<Record<string, unknown>> }) {
|
|
received = await props.searchParams;
|
|
}
|
|
|
|
await probeAppPage({
|
|
pageComponent: Page,
|
|
asyncRouteParams: makeThenableParams({}),
|
|
searchParams: null,
|
|
});
|
|
|
|
expect(received).toBeDefined();
|
|
expect(Object.keys(received ?? {})).toEqual([]);
|
|
});
|
|
|
|
it("does not mark dynamic when a cached page probe only derives its cache key", async () => {
|
|
const CachedPage = await registerCachedProbePage(
|
|
async (props: {
|
|
params: Promise<{ slug: string }>;
|
|
searchParams: Promise<Record<string, unknown>>;
|
|
}) => {
|
|
const params = await props.params;
|
|
return `slug:${params.slug}`;
|
|
},
|
|
"test:probe-page-props-searchparams-inert",
|
|
);
|
|
|
|
await probeAppPage({
|
|
pageComponent: CachedPage,
|
|
asyncRouteParams: makeThenableParams({ slug: "intro" }),
|
|
searchParams: new URLSearchParams("q=hello"),
|
|
});
|
|
|
|
expect(consumeDynamicUsage()).toBe(false);
|
|
expect(consumeRenderRequestApiUsage()).toEqual([]);
|
|
});
|
|
|
|
it("rejects when a cached page probe awaits searchParams in the page body", async () => {
|
|
const CachedPage = await registerCachedProbePage(
|
|
async (props: { searchParams: Promise<{ q?: string }> }) => {
|
|
const searchParams = await props.searchParams;
|
|
return searchParams.q ?? "";
|
|
},
|
|
"test:probe-page-props-searchparams-observed",
|
|
);
|
|
|
|
await expect(
|
|
probeAppPage({
|
|
pageComponent: CachedPage,
|
|
asyncRouteParams: makeThenableParams({}),
|
|
searchParams: new URLSearchParams("q=hello"),
|
|
}),
|
|
).rejects.toThrow(/cannot be called inside "use cache"/);
|
|
});
|
|
|
|
it("lets redirect()/notFound() throws propagate so the probe lifecycle can catch them", async () => {
|
|
const REDIRECT = new Error("NEXT_REDIRECT");
|
|
async function Page(props: { searchParams: Promise<{ dest?: string }> }) {
|
|
const { dest } = await props.searchParams;
|
|
if (dest) throw REDIRECT;
|
|
}
|
|
|
|
const result = probeAppPage({
|
|
pageComponent: Page,
|
|
asyncRouteParams: makeThenableParams({}),
|
|
searchParams: new URLSearchParams("dest=/about"),
|
|
}) as Promise<unknown>;
|
|
|
|
await expect(result).rejects.toBe(REDIRECT);
|
|
});
|
|
});
|
|
|
|
// buildAppPageProbes() fans out the per-request page probes (matched page +
|
|
// active parallel slots + interception page). Extracted out of the generated
|
|
// RSC entry so the fan-out is unit-testable (AGENTS.md: keep generated entries
|
|
// thin). These tests pin which page components get probed for a request.
|
|
describe("buildAppPageProbes", () => {
|
|
// Loosely-typed adapter matching the helper's `(params: unknown) => unknown`
|
|
// signature, so the test's generic makeThenableParams can be passed in.
|
|
const makeThenableParamsLoose = (params: unknown): unknown =>
|
|
makeThenableParams((params ?? {}) as Record<string, unknown>);
|
|
|
|
function recordingPage(label: string, sink: string[]) {
|
|
return function Page(props: { searchParams: Promise<Record<string, unknown>> }) {
|
|
void props;
|
|
sink.push(label);
|
|
return label;
|
|
};
|
|
}
|
|
|
|
it("probes the matched page, every slot page, and the interception page", async () => {
|
|
const probed: string[] = [];
|
|
const probes = buildAppPageProbes({
|
|
route: {
|
|
slots: {
|
|
modal: { page: { default: recordingPage("modal", probed) } },
|
|
sidebar: { page: { default: recordingPage("sidebar", probed) } },
|
|
},
|
|
},
|
|
pageComponent: recordingPage("page", probed),
|
|
asyncRouteParams: makeThenableParams({ slug: "intro" }),
|
|
searchParams: new URLSearchParams("q=hello"),
|
|
intercept: { page: { default: recordingPage("intercept", probed) } },
|
|
isRscRequest: true,
|
|
matchedParams: { slug: "intro" },
|
|
makeThenableParams: makeThenableParamsLoose,
|
|
});
|
|
|
|
await Promise.all(probes);
|
|
|
|
expect(probes).toHaveLength(4);
|
|
expect(probed.sort()).toEqual(["intercept", "modal", "page", "sidebar"]);
|
|
});
|
|
|
|
it("ignores the interception match for non-RSC (HTML) requests", async () => {
|
|
const probed: string[] = [];
|
|
const probes = buildAppPageProbes({
|
|
route: {
|
|
slots: {
|
|
// On an HTML request interception never fires, so the modal slot
|
|
// renders its own page and must be probed; the interception page
|
|
// (which never renders) must NOT be probed.
|
|
modal: { page: { default: recordingPage("modal", probed) } },
|
|
},
|
|
},
|
|
pageComponent: recordingPage("page", probed),
|
|
asyncRouteParams: makeThenableParams({}),
|
|
searchParams: new URLSearchParams("q=hello"),
|
|
intercept: {
|
|
slotKey: "modal",
|
|
page: { default: recordingPage("intercept", probed) },
|
|
},
|
|
isRscRequest: false,
|
|
matchedParams: {},
|
|
makeThenableParams: makeThenableParamsLoose,
|
|
});
|
|
|
|
await Promise.all(probes);
|
|
|
|
expect(probed.sort()).toEqual(["modal", "page"]);
|
|
expect(probed).not.toContain("intercept");
|
|
});
|
|
|
|
it("probes the interception page in place of the slot it overrides", async () => {
|
|
const probed: string[] = [];
|
|
const probes = buildAppPageProbes({
|
|
route: {
|
|
slots: {
|
|
// modal slot is overridden by the active interception below, so its
|
|
// own page must NOT be probed (it never renders for this request).
|
|
modal: { page: { default: recordingPage("modal-page", probed) } },
|
|
sidebar: { page: { default: recordingPage("sidebar", probed) } },
|
|
},
|
|
},
|
|
pageComponent: recordingPage("page", probed),
|
|
asyncRouteParams: makeThenableParams({}),
|
|
searchParams: new URLSearchParams("q=hello"),
|
|
intercept: {
|
|
slotKey: "modal",
|
|
page: { default: recordingPage("intercept", probed) },
|
|
},
|
|
isRscRequest: true,
|
|
matchedParams: {},
|
|
makeThenableParams: makeThenableParamsLoose,
|
|
});
|
|
|
|
await Promise.all(probes);
|
|
|
|
// modal-page is skipped (overridden); intercept renders in its place.
|
|
expect(probed.sort()).toEqual(["intercept", "page", "sidebar"]);
|
|
expect(probed).not.toContain("modal-page");
|
|
});
|
|
|
|
it("omits the interception probe when no interception matches", async () => {
|
|
const probed: string[] = [];
|
|
const probes = buildAppPageProbes({
|
|
route: { slots: { modal: { page: { default: recordingPage("modal", probed) } } } },
|
|
pageComponent: recordingPage("page", probed),
|
|
asyncRouteParams: makeThenableParams({}),
|
|
searchParams: null,
|
|
intercept: null,
|
|
isRscRequest: true,
|
|
matchedParams: {},
|
|
makeThenableParams: makeThenableParamsLoose,
|
|
});
|
|
|
|
await Promise.all(probes);
|
|
|
|
expect(probes).toHaveLength(2);
|
|
expect(probed.sort()).toEqual(["modal", "page"]);
|
|
});
|
|
|
|
it("skips slots without a page default export", async () => {
|
|
const probed: string[] = [];
|
|
const probes = buildAppPageProbes({
|
|
route: {
|
|
slots: {
|
|
modal: { page: { default: recordingPage("modal", probed) } },
|
|
// default-only slot: no page component to probe
|
|
children: { page: null },
|
|
empty: null,
|
|
},
|
|
},
|
|
pageComponent: recordingPage("page", probed),
|
|
asyncRouteParams: makeThenableParams({}),
|
|
searchParams: null,
|
|
isRscRequest: true,
|
|
matchedParams: {},
|
|
makeThenableParams: makeThenableParamsLoose,
|
|
});
|
|
|
|
await Promise.all(probes);
|
|
|
|
// One probe per slot is created, but only real page components run.
|
|
expect(probed.sort()).toEqual(["modal", "page"]);
|
|
});
|
|
|
|
it("falls back to matchedParams when an interception match omits its own params", async () => {
|
|
const receivedParams: unknown[] = [];
|
|
function InterceptPage(props: { params: Promise<Record<string, unknown>> }) {
|
|
receivedParams.push(props.params);
|
|
return "intercept";
|
|
}
|
|
|
|
const fallbackParams = { username: "ada" };
|
|
const probes = buildAppPageProbes({
|
|
route: {},
|
|
pageComponent: () => "page",
|
|
asyncRouteParams: makeThenableParams({}),
|
|
searchParams: null,
|
|
intercept: { page: { default: InterceptPage } },
|
|
isRscRequest: true,
|
|
matchedParams: fallbackParams,
|
|
makeThenableParams: makeThenableParamsLoose,
|
|
});
|
|
|
|
await Promise.all(probes);
|
|
|
|
expect(receivedParams).toHaveLength(1);
|
|
expect(await (receivedParams[0] as Promise<Record<string, unknown>>)).toEqual(fallbackParams);
|
|
});
|
|
});
|