mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
dabcb55e63
* fix(app-router): expose active source page on window.next App Router pages did not update window.next.__internal_src_page, so Next.js-compatible tooling and deploy tests could not observe the active source page on initial render or client transitions. The payload only exposed URL route IDs, which drop route groups and bracketed dynamic segment names. The fix carries a source-page metadata field from the app route's filesystem segments, reads it from committed browser router state, and mirrors it onto window.next like Next.js does. Adds regression coverage for route-group and dynamic source-page encoding plus the window.next writer contract ported from the upstream app-dir test. * fix(app-router): preserve source page across boundary renders * fix(app-router): tolerate malformed source page metadata * fix(app-router): preserve intercepted fallback source page * fix(app-router): omit unknown boundary source page --------- Co-authored-by: James <james@eli.cx>
1244 lines
41 KiB
TypeScript
1244 lines
41 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
|
|
import React from "react";
|
|
import {
|
|
APP_INTERCEPTION_KEY,
|
|
APP_INTERCEPTION_CONTEXT_KEY,
|
|
APP_LAYOUT_IDS_KEY,
|
|
APP_ROOT_LAYOUT_KEY,
|
|
APP_ROUTE_KEY,
|
|
APP_SOURCE_PAGE_KEY,
|
|
APP_SLOT_BINDINGS_KEY,
|
|
} from "../packages/vinext/src/server/app-elements.js";
|
|
import type { AppPageModule } from "../packages/vinext/src/server/app-page-route-wiring.js";
|
|
import type { AppPageParams } from "../packages/vinext/src/server/app-page-boundary.js";
|
|
import { makeThenableParams } from "../packages/vinext/src/shims/thenable-params.js";
|
|
import { readStreamAsText } from "../packages/vinext/src/utils/text-stream.js";
|
|
|
|
// Import the function under test AFTER mocking dependencies.
|
|
// eslint-disable-next-line import/first
|
|
import {
|
|
buildPageElements,
|
|
resolveAppPageNavigationParams,
|
|
type AppPageBuildRoute,
|
|
} from "../packages/vinext/src/server/app-page-element-builder.js";
|
|
import { probeAppPage } from "../packages/vinext/src/server/app-page-probe.js";
|
|
import { SIBLING_PAGE_INTERCEPT_SLOT_KEY } from "../packages/vinext/src/server/app-rsc-route-matching.js";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mocks
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const { markDynamicUsageMock, markRenderRequestApiUsageMock } = vi.hoisted(() => ({
|
|
markDynamicUsageMock: vi.fn(),
|
|
markRenderRequestApiUsageMock: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../packages/vinext/src/shims/headers.js", () => ({
|
|
markDynamicUsage: markDynamicUsageMock,
|
|
markRenderRequestApiUsage: markRenderRequestApiUsageMock,
|
|
throwIfInsideCacheScope: vi.fn(),
|
|
}));
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function createSyntheticRoute(overrides?: Partial<AppPageBuildRoute>): AppPageBuildRoute {
|
|
return {
|
|
layouts: [],
|
|
pattern: "/test",
|
|
routeSegments: [] as readonly string[],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createSyntheticPageModule(defaultExport?: unknown): AppPageModule {
|
|
if (defaultExport !== undefined) {
|
|
return { default: defaultExport } as AppPageModule;
|
|
}
|
|
return {} as AppPageModule;
|
|
}
|
|
|
|
function createSyntheticPageModuleWithoutDefault(): AppPageModule {
|
|
return { generateMetadata: vi.fn() } as AppPageModule;
|
|
}
|
|
|
|
function createBaseOptions(overrides?: {
|
|
route?: AppPageBuildRoute;
|
|
params?: AppPageParams;
|
|
routePath?: string;
|
|
opts?: Record<string, unknown> | null;
|
|
searchParams?: URLSearchParams | null;
|
|
mountedSlotsHeader?: string | null;
|
|
}) {
|
|
return {
|
|
route:
|
|
overrides?.route ?? createSyntheticRoute({ page: createSyntheticPageModule(() => null) }),
|
|
params: overrides?.params ?? {},
|
|
routePath: overrides?.routePath ?? "/test",
|
|
pageRequest: {
|
|
opts: overrides?.opts ?? null,
|
|
searchParams: overrides?.searchParams ?? null,
|
|
isRscRequest: false,
|
|
request: new Request("http://localhost/test"),
|
|
mountedSlotsHeader: overrides?.mountedSlotsHeader ?? null,
|
|
},
|
|
globalErrorModule: null,
|
|
rootNotFoundModule: null,
|
|
rootForbiddenModule: null,
|
|
rootUnauthorizedModule: null,
|
|
metadataRoutes: [],
|
|
};
|
|
}
|
|
|
|
async function buildSearchPageSearchParams(options?: {
|
|
loadingBoundary?: boolean;
|
|
}): Promise<{ searchParams: Promise<Record<string, unknown>> }> {
|
|
function SearchPage(): React.ReactNode {
|
|
return React.createElement("div", null, "Search");
|
|
}
|
|
|
|
const route = createSyntheticRoute({
|
|
page: createSyntheticPageModule(SearchPage),
|
|
loading: options?.loadingBoundary ? { default: () => null } : undefined,
|
|
layouts: [],
|
|
routeSegments: ["search"],
|
|
pattern: "/search",
|
|
});
|
|
|
|
const result = await buildPageElements(
|
|
createBaseOptions({
|
|
route,
|
|
routePath: "/search",
|
|
searchParams: new URLSearchParams("q=test"),
|
|
}),
|
|
);
|
|
const record = result as Record<string, unknown>;
|
|
const pageElement = record["page:/search"];
|
|
if (!React.isValidElement<{ searchParams?: Promise<Record<string, unknown>> }>(pageElement)) {
|
|
throw new Error("Expected page element");
|
|
}
|
|
if (!pageElement.props.searchParams) {
|
|
throw new Error("Expected searchParams prop");
|
|
}
|
|
|
|
return { searchParams: pageElement.props.searchParams };
|
|
}
|
|
|
|
async function resetUseCacheRuntime(): Promise<void> {
|
|
const { MemoryCacheHandler, setCacheHandler } =
|
|
await import("../packages/vinext/src/shims/cache.js");
|
|
setCacheHandler(new MemoryCacheHandler());
|
|
}
|
|
|
|
async function renderNode(node: React.ReactNode): Promise<string> {
|
|
const { renderToReadableStream } = await import("react-dom/server.edge");
|
|
const stream = await renderToReadableStream(node, {
|
|
onError(error: unknown) {
|
|
throw error instanceof Error ? error : new Error(String(error));
|
|
},
|
|
});
|
|
return readStreamAsText(stream);
|
|
}
|
|
|
|
async function buildAndRenderElement(
|
|
route: AppPageBuildRoute,
|
|
elementId: string,
|
|
query: string,
|
|
): Promise<string> {
|
|
const result = await buildPageElements(
|
|
createBaseOptions({
|
|
route,
|
|
params: { slug: "same" },
|
|
routePath: "/cached",
|
|
searchParams: new URLSearchParams({ q: query }),
|
|
}),
|
|
);
|
|
const record = result as Record<string, unknown>;
|
|
const element = record[elementId];
|
|
if (!React.isValidElement(element)) {
|
|
throw new Error(`Expected React element for ${elementId}`);
|
|
}
|
|
|
|
markDynamicUsageMock.mockClear();
|
|
markRenderRequestApiUsageMock.mockClear();
|
|
return renderNode(element);
|
|
}
|
|
|
|
function expectNoSearchParamsObservation(): void {
|
|
expect(markDynamicUsageMock).not.toHaveBeenCalled();
|
|
expect(markRenderRequestApiUsageMock).not.toHaveBeenCalled();
|
|
}
|
|
|
|
async function expectCachedRenderIgnoresQuery(options: {
|
|
expectedText: string;
|
|
getCallCount: () => number;
|
|
render: (query: string) => Promise<string>;
|
|
}): Promise<void> {
|
|
await expect(options.render("first")).resolves.toContain(options.expectedText);
|
|
expect(options.getCallCount()).toBe(1);
|
|
expectNoSearchParamsObservation();
|
|
|
|
await expect(options.render("second")).resolves.toContain(options.expectedText);
|
|
expect(options.getCallCount()).toBe(1);
|
|
expectNoSearchParamsObservation();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("buildPageElements", () => {
|
|
beforeEach(() => {
|
|
markDynamicUsageMock.mockClear();
|
|
markRenderRequestApiUsageMock.mockClear();
|
|
});
|
|
|
|
it("returns an error element record when a page module has no default export", async () => {
|
|
const route = createSyntheticRoute({
|
|
page: createSyntheticPageModuleWithoutDefault(),
|
|
layouts: [],
|
|
routeSegments: ["test"],
|
|
pattern: "/test",
|
|
});
|
|
|
|
const result = await buildPageElements(createBaseOptions({ route }));
|
|
|
|
const record = result as Record<string, unknown>;
|
|
// The error payload uses the AppElementsWire route-id encoder for both
|
|
// the entry key and the __route metadata.
|
|
expect(record[APP_ROUTE_KEY]).toBe("route:/test");
|
|
expect(record[APP_INTERCEPTION_CONTEXT_KEY]).toBe(null);
|
|
expect(Object.prototype.hasOwnProperty.call(record, APP_ROOT_LAYOUT_KEY)).toBe(true);
|
|
// The element itself is stored under the route ID key.
|
|
expect(record["route:/test"]).toBeDefined();
|
|
});
|
|
|
|
it("includes active parallel slot params in navigation params", () => {
|
|
// Ported from Next.js:
|
|
// test/e2e/app-dir/parallel-route-navigations/parallel-route-navigations.test.ts
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/parallel-route-navigations/parallel-route-navigations.test.ts
|
|
//
|
|
// Next.js derives useParams() from the active FlightRouterState, walking all
|
|
// parallel route branches via getSelectedParams(). A slot catch-all branch
|
|
// must therefore contribute params even when the primary children route is
|
|
// static below the parent dynamic segment.
|
|
const route = createSyntheticRoute({
|
|
page: createSyntheticPageModule(() => null),
|
|
layouts: [createSyntheticPageModule(() => null)],
|
|
params: ["teamID"],
|
|
pattern: "/:teamID/sub/folder",
|
|
routeSegments: ["[teamID]", "sub", "folder"],
|
|
slots: {
|
|
"slot@app/[teamID]/@slot": {
|
|
name: "slot",
|
|
page: createSyntheticPageModule(() => null),
|
|
default: createSyntheticPageModule(() => null),
|
|
layoutIndex: 0,
|
|
routeSegments: ["[...catchAll]"],
|
|
slotPatternParts: [":teamID", ":catchAll+"],
|
|
slotParamNames: ["teamID", "catchAll"],
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(
|
|
resolveAppPageNavigationParams(route, { teamID: "vercel" }, "/vercel/sub/folder", null),
|
|
).toEqual({
|
|
teamID: "vercel",
|
|
catchAll: ["sub", "folder"],
|
|
});
|
|
});
|
|
|
|
it("resolves navigation params with duplicate/colliding slot and route param keys", () => {
|
|
const route = createSyntheticRoute({
|
|
page: createSyntheticPageModule(() => null),
|
|
layouts: [createSyntheticPageModule(() => null)],
|
|
params: ["id"],
|
|
pattern: "/:id",
|
|
routeSegments: ["[id]"],
|
|
slots: {
|
|
"slot@app/[id]/@slot": {
|
|
name: "slot",
|
|
page: createSyntheticPageModule(() => null),
|
|
default: createSyntheticPageModule(() => null),
|
|
layoutIndex: 0,
|
|
routeSegments: ["[id]"],
|
|
slotPatternParts: [":id", ":catchAll+"],
|
|
slotParamNames: ["id", "catchAll"],
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(
|
|
resolveAppPageNavigationParams(route, { id: "primary" }, "/slot-override/sub/folder", null),
|
|
).toEqual({
|
|
id: "slot-override",
|
|
catchAll: ["sub", "folder"],
|
|
});
|
|
});
|
|
|
|
it("merges non-intercepted active slot params alongside intercepted slot params", () => {
|
|
// Both slots have catch-all patterns that match the same URL; only @slot
|
|
// is intercepted, so @other should still contribute its catch-all.
|
|
const route = createSyntheticRoute({
|
|
page: createSyntheticPageModule(() => null),
|
|
layouts: [createSyntheticPageModule(() => null)],
|
|
params: ["teamID"],
|
|
pattern: "/:teamID/sub/folder",
|
|
routeSegments: ["[teamID]", "sub", "folder"],
|
|
slots: {
|
|
"slot@app/[teamID]/@slot": {
|
|
name: "slot",
|
|
page: createSyntheticPageModule(() => null),
|
|
default: createSyntheticPageModule(() => null),
|
|
layoutIndex: 0,
|
|
routeSegments: ["[...catchAll]"],
|
|
slotPatternParts: [":teamID", ":catchAll+"],
|
|
slotParamNames: ["teamID", "catchAll"],
|
|
},
|
|
"slot@app/[teamID]/@other": {
|
|
name: "other",
|
|
page: createSyntheticPageModule(() => null),
|
|
default: createSyntheticPageModule(() => null),
|
|
layoutIndex: 0,
|
|
routeSegments: ["[...otherCatchAll]"],
|
|
slotPatternParts: [":teamID", ":otherCatchAll+"],
|
|
slotParamNames: ["teamID", "otherCatchAll"],
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(
|
|
resolveAppPageNavigationParams(route, { teamID: "vercel" }, "/vercel/sub/folder", {
|
|
interceptSlotKey: "slot@app/[teamID]/@slot",
|
|
interceptPage: { default: vi.fn() },
|
|
interceptParams: { teamID: "vercel", catchAll: ["intercepted-override"] },
|
|
}),
|
|
).toEqual({
|
|
teamID: "vercel",
|
|
catchAll: ["intercepted-override"],
|
|
otherCatchAll: ["sub", "folder"],
|
|
});
|
|
});
|
|
|
|
it("uses interceptParams for an intercepted slot instead of slotParamOverrides", () => {
|
|
const route = createSyntheticRoute({
|
|
page: createSyntheticPageModule(() => null),
|
|
layouts: [createSyntheticPageModule(() => null)],
|
|
params: ["teamID"],
|
|
pattern: "/:teamID/sub/folder",
|
|
routeSegments: ["[teamID]", "sub", "folder"],
|
|
slots: {
|
|
"slot@app/[teamID]/@slot": {
|
|
name: "slot",
|
|
page: createSyntheticPageModule(() => null),
|
|
default: createSyntheticPageModule(() => null),
|
|
layoutIndex: 0,
|
|
routeSegments: ["[...catchAll]"],
|
|
slotPatternParts: [":teamID", ":catchAll+"],
|
|
slotParamNames: ["teamID", "catchAll"],
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(
|
|
resolveAppPageNavigationParams(route, { teamID: "vercel" }, "/vercel/sub/folder", {
|
|
interceptSlotKey: "slot@app/[teamID]/@slot",
|
|
interceptPage: { default: vi.fn() },
|
|
interceptParams: { teamID: "vercel", catchAll: ["intercepted", "override"] },
|
|
}),
|
|
).toEqual({
|
|
teamID: "vercel",
|
|
catchAll: ["intercepted", "override"],
|
|
});
|
|
});
|
|
|
|
it("surfaces a no-default-export error for a sibling intercept page instead of rendering the source page", async () => {
|
|
function SourcePage(): React.ReactNode {
|
|
return React.createElement("div", null, "Source page content");
|
|
}
|
|
|
|
const route = createSyntheticRoute({
|
|
// The source route has a valid default export, so the dispatch-level
|
|
// no-export guard passes and the request reaches buildPageElements.
|
|
page: createSyntheticPageModule(SourcePage),
|
|
layouts: [],
|
|
routeSegments: ["photo", "[id]"],
|
|
pattern: "/photo/[id]",
|
|
});
|
|
|
|
const result = await buildPageElements(
|
|
createBaseOptions({
|
|
route,
|
|
routePath: "/photo/42",
|
|
opts: {
|
|
interceptSlotKey: SIBLING_PAGE_INTERCEPT_SLOT_KEY,
|
|
// The intercepting page module is missing its `default` export.
|
|
interceptPage: createSyntheticPageModuleWithoutDefault(),
|
|
interceptParams: { id: "42" },
|
|
} as Record<string, unknown>,
|
|
}),
|
|
);
|
|
|
|
const record = result as Record<string, unknown>;
|
|
const routeKey = record[APP_ROUTE_KEY] as string;
|
|
const errorElement = record[routeKey];
|
|
expect(React.isValidElement(errorElement)).toBe(true);
|
|
|
|
const html = await renderNode(errorElement as React.ReactNode);
|
|
// The error is surfaced explicitly rather than silently falling back to
|
|
// the source route's page component.
|
|
expect(html).toContain("Page has no default export");
|
|
expect(html).not.toContain("Source page content");
|
|
});
|
|
|
|
it("publishes the intercepting page path for sibling interception", async () => {
|
|
function InterceptPage(): React.ReactNode {
|
|
return React.createElement("div", null, "Intercepted");
|
|
}
|
|
|
|
const route = createSyntheticRoute({
|
|
page: createSyntheticPageModule(() => React.createElement("div", null, "Source")),
|
|
layouts: [],
|
|
routeSegments: ["foo", "bar"],
|
|
pattern: "/foo/bar",
|
|
});
|
|
|
|
const result = await buildPageElements(
|
|
createBaseOptions({
|
|
route,
|
|
routePath: "/hoge",
|
|
opts: {
|
|
interceptSlotKey: SIBLING_PAGE_INTERCEPT_SLOT_KEY,
|
|
interceptPage: createSyntheticPageModule(InterceptPage),
|
|
interceptParams: {},
|
|
interceptSourcePageSegments: ["foo", "bar", "(..)(..)hoge"],
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect((result as Record<string, unknown>)[APP_SOURCE_PAGE_KEY]).toBe(
|
|
"/foo/bar/(..)(..)hoge/page",
|
|
);
|
|
});
|
|
|
|
it("keeps interception context out of the error payload route ID", async () => {
|
|
const route = createSyntheticRoute({
|
|
page: createSyntheticPageModuleWithoutDefault(),
|
|
layouts: [],
|
|
routeSegments: ["intercepted"],
|
|
pattern: "/intercepted",
|
|
});
|
|
|
|
const result = await buildPageElements(
|
|
createBaseOptions({
|
|
route,
|
|
routePath: "/intercepted",
|
|
opts: { interceptionContext: "ctx-abc" } as Record<string, unknown>,
|
|
}),
|
|
);
|
|
|
|
const record = result as Record<string, unknown>;
|
|
expect(record[APP_ROUTE_KEY]).toBe("route:/intercepted");
|
|
expect(record[APP_INTERCEPTION_CONTEXT_KEY]).toBe("ctx-abc");
|
|
});
|
|
|
|
it("computes root layout tree path for error payload when layouts exist", async () => {
|
|
const route = createSyntheticRoute({
|
|
page: createSyntheticPageModuleWithoutDefault(),
|
|
layouts: [createSyntheticPageModule(() => null), createSyntheticPageModule(() => null)],
|
|
layoutTreePositions: [0, 1],
|
|
routeSegments: ["dashboard", "settings"],
|
|
pattern: "/dashboard/settings",
|
|
});
|
|
|
|
const result = await buildPageElements(createBaseOptions({ route }));
|
|
|
|
const record = result as Record<string, unknown>;
|
|
expect(record[APP_ROOT_LAYOUT_KEY]).toBe("/");
|
|
expect(record[APP_LAYOUT_IDS_KEY]).toEqual(["layout:/", "layout:/dashboard"]);
|
|
});
|
|
|
|
it("constructs a full element tree for a page with a default export", async () => {
|
|
function TestPage(): React.ReactNode {
|
|
return React.createElement("div", null, "Hello");
|
|
}
|
|
|
|
const route = createSyntheticRoute({
|
|
page: createSyntheticPageModule(TestPage),
|
|
layouts: [],
|
|
routeSegments: ["hello"],
|
|
pattern: "/hello",
|
|
});
|
|
|
|
const result = await buildPageElements(createBaseOptions({ route, routePath: "/hello" }));
|
|
|
|
const record = result as Record<string, unknown>;
|
|
// Normal flow: the element tree has both route and page payload IDs.
|
|
expect(record[APP_ROUTE_KEY]).toBe("route:/hello");
|
|
expect(Object.prototype.hasOwnProperty.call(record, "page:/hello")).toBe(true);
|
|
expect(Object.prototype.hasOwnProperty.call(record, "route:/hello")).toBe(true);
|
|
});
|
|
|
|
it("attaches route-state slot bindings for active, default, and unmatched slots", async () => {
|
|
function TestPage(): React.ReactNode {
|
|
return React.createElement("div", null, "Hello");
|
|
}
|
|
function TestLayout({ children }: { children?: React.ReactNode }): React.ReactNode {
|
|
return children;
|
|
}
|
|
|
|
const route = createSyntheticRoute({
|
|
page: createSyntheticPageModule(TestPage),
|
|
layouts: [createSyntheticPageModule(TestLayout)],
|
|
layoutTreePositions: [0],
|
|
routeSegments: ["dashboard"],
|
|
pattern: "/dashboard",
|
|
slots: {
|
|
"team@dashboard/@team": {
|
|
id: "slot:team:/",
|
|
name: "team",
|
|
page: createSyntheticPageModule(() => React.createElement("div", null, "team")),
|
|
layoutIndex: 0,
|
|
routeSegments: [],
|
|
},
|
|
"analytics@dashboard/@analytics": {
|
|
id: "slot:analytics:/",
|
|
name: "analytics",
|
|
default: createSyntheticPageModule(() => React.createElement("div", null, "analytics")),
|
|
layoutIndex: 0,
|
|
routeSegments: null,
|
|
},
|
|
"reports@dashboard/@reports": {
|
|
id: "slot:reports:/",
|
|
name: "reports",
|
|
layoutIndex: 0,
|
|
routeSegments: null,
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = await buildPageElements(createBaseOptions({ route, routePath: "/dashboard" }));
|
|
const record = result as Record<string, unknown>;
|
|
|
|
expect(record[APP_SLOT_BINDINGS_KEY]).toEqual([
|
|
{
|
|
ownerLayoutId: "layout:/",
|
|
slotId: "slot:analytics:/",
|
|
state: "default",
|
|
},
|
|
{
|
|
ownerLayoutId: "layout:/",
|
|
slotId: "slot:reports:/",
|
|
state: "unmatched",
|
|
},
|
|
{
|
|
activeRouteId: "route:/dashboard",
|
|
ownerLayoutId: "layout:/",
|
|
slotId: "slot:team:/",
|
|
state: "active",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("marks intercepted slot override bindings as active", async () => {
|
|
function TestPage(): React.ReactNode {
|
|
return React.createElement("div", null, "Hello");
|
|
}
|
|
function TestLayout({ children }: { children?: React.ReactNode }): React.ReactNode {
|
|
return children;
|
|
}
|
|
|
|
const route = createSyntheticRoute({
|
|
page: createSyntheticPageModule(TestPage),
|
|
layouts: [createSyntheticPageModule(TestLayout), createSyntheticPageModule(TestLayout)],
|
|
layoutTreePositions: [0, 1],
|
|
routeSegments: ["feed"],
|
|
pattern: "/feed",
|
|
slots: {
|
|
"modal@feed/@modal": {
|
|
id: "slot:modal:/feed",
|
|
name: "modal",
|
|
default: createSyntheticPageModule(() => null),
|
|
layoutIndex: 1,
|
|
routeSegments: null,
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = await buildPageElements(
|
|
createBaseOptions({
|
|
route,
|
|
routePath: "/photos/42",
|
|
opts: {
|
|
interceptionContext: "/feed",
|
|
interceptSlotKey: "modal@feed/@modal",
|
|
interceptPage: createSyntheticPageModule(() =>
|
|
React.createElement("div", null, "Intercepted"),
|
|
),
|
|
interceptParams: { id: "42" },
|
|
} as Record<string, unknown>,
|
|
}),
|
|
);
|
|
const record = result as Record<string, unknown>;
|
|
|
|
expect(record[APP_SLOT_BINDINGS_KEY]).toEqual([
|
|
{
|
|
activeRouteId: "route:/photos/42",
|
|
ownerLayoutId: "layout:/feed",
|
|
slotId: "slot:modal:/feed",
|
|
state: "active",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("uses the source route identity for intercepted source-route payload keys", async () => {
|
|
function TestPage(): React.ReactNode {
|
|
return React.createElement("div", null, "Feed");
|
|
}
|
|
function TestLayout({ children }: { children?: React.ReactNode }): React.ReactNode {
|
|
return children;
|
|
}
|
|
|
|
const route = createSyntheticRoute({
|
|
page: createSyntheticPageModule(TestPage),
|
|
layouts: [createSyntheticPageModule(TestLayout), createSyntheticPageModule(TestLayout)],
|
|
layoutTreePositions: [0, 1],
|
|
routeSegments: ["feed"],
|
|
pattern: "/feed",
|
|
slots: {
|
|
"modal@feed/@modal": {
|
|
id: "slot:modal:/feed",
|
|
name: "modal",
|
|
default: createSyntheticPageModule(() => null),
|
|
layoutIndex: 1,
|
|
routeSegments: null,
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = await buildPageElements(
|
|
createBaseOptions({
|
|
route,
|
|
routePath: "/photos/42",
|
|
opts: {
|
|
interceptionContext: "/feed",
|
|
interceptSourceMatchedUrl: "/feed",
|
|
interceptSlotId: "slot:modal:/feed",
|
|
interceptSlotKey: "modal@feed/@modal",
|
|
interceptPage: createSyntheticPageModule(() =>
|
|
React.createElement("div", null, "Intercepted"),
|
|
),
|
|
interceptParams: { id: "42" },
|
|
} as Record<string, unknown>,
|
|
}),
|
|
);
|
|
const record = result as Record<string, unknown>;
|
|
|
|
expect(record[APP_ROUTE_KEY]).toBe("route:/feed");
|
|
expect(Object.prototype.hasOwnProperty.call(record, "route:/feed")).toBe(true);
|
|
expect(Object.prototype.hasOwnProperty.call(record, "page:/feed")).toBe(true);
|
|
expect(Object.prototype.hasOwnProperty.call(record, "route:/photos/42\0/feed")).toBe(false);
|
|
expect(Object.prototype.hasOwnProperty.call(record, "page:/photos/42\0/feed")).toBe(false);
|
|
expect(record[APP_INTERCEPTION_KEY]).toEqual({
|
|
sourceMatchedUrl: "/feed",
|
|
sourceRouteId: "route:/feed",
|
|
slotId: "slot:modal:/feed",
|
|
targetMatchedUrl: "/photos/42",
|
|
targetRouteId: "route:/photos/42",
|
|
});
|
|
});
|
|
|
|
it("normalizes encoded interception proof paths before encoding route IDs", async () => {
|
|
function TestPage(): React.ReactNode {
|
|
return React.createElement("div", null, "Hello");
|
|
}
|
|
function TestLayout({ children }: { children?: React.ReactNode }): React.ReactNode {
|
|
return children;
|
|
}
|
|
|
|
const route = createSyntheticRoute({
|
|
page: createSyntheticPageModule(TestPage),
|
|
layouts: [createSyntheticPageModule(TestLayout), createSyntheticPageModule(TestLayout)],
|
|
layoutTreePositions: [0, 1],
|
|
routeSegments: ["café"],
|
|
pattern: "/café",
|
|
slots: {
|
|
"modal@café/@modal": {
|
|
id: "slot:modal:/café",
|
|
name: "modal",
|
|
default: createSyntheticPageModule(() => null),
|
|
layoutIndex: 1,
|
|
routeSegments: null,
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = await buildPageElements(
|
|
createBaseOptions({
|
|
route,
|
|
routePath: "/photos/caf%C3%A9",
|
|
opts: {
|
|
interceptionContext: "/caf%C3%A9",
|
|
interceptSourceMatchedUrl: "/caf%C3%A9",
|
|
interceptSlotId: "slot:modal:/café",
|
|
interceptSlotKey: "modal@café/@modal",
|
|
interceptPage: createSyntheticPageModule(() =>
|
|
React.createElement("div", null, "Intercepted"),
|
|
),
|
|
interceptParams: { id: "café" },
|
|
} as Record<string, unknown>,
|
|
}),
|
|
);
|
|
const record = result as Record<string, unknown>;
|
|
|
|
expect(record[APP_INTERCEPTION_KEY]).toEqual({
|
|
sourceMatchedUrl: "/café",
|
|
sourceRouteId: "route:/café",
|
|
slotId: "slot:modal:/café",
|
|
targetMatchedUrl: "/photos/café",
|
|
targetRouteId: "route:/photos/café",
|
|
});
|
|
});
|
|
|
|
it("rejects graph slot ids that diverge from the wire slot id", async () => {
|
|
function TestPage(): React.ReactNode {
|
|
return React.createElement("div", null, "Hello");
|
|
}
|
|
function TestLayout({ children }: { children?: React.ReactNode }): React.ReactNode {
|
|
return children;
|
|
}
|
|
|
|
const route = createSyntheticRoute({
|
|
page: createSyntheticPageModule(TestPage),
|
|
layouts: [createSyntheticPageModule(TestLayout), createSyntheticPageModule(TestLayout)],
|
|
layoutTreePositions: [0, 1],
|
|
routeSegments: ["feed"],
|
|
pattern: "/feed",
|
|
slots: {
|
|
"modal@feed/@modal": {
|
|
id: "slot:modal:/wrong",
|
|
name: "modal",
|
|
default: createSyntheticPageModule(() => null),
|
|
layoutIndex: 1,
|
|
routeSegments: null,
|
|
},
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
buildPageElements(createBaseOptions({ route, routePath: "/feed" })),
|
|
).rejects.toThrow("App Router slot id mismatch");
|
|
});
|
|
|
|
it("does NOT call markDynamicUsage while wiring searchParams into the render tree", async () => {
|
|
function SearchPage(): React.ReactNode {
|
|
return React.createElement("div", null, "Search");
|
|
}
|
|
|
|
const route = createSyntheticRoute({
|
|
page: createSyntheticPageModule(SearchPage),
|
|
layouts: [],
|
|
routeSegments: ["search"],
|
|
pattern: "/search",
|
|
});
|
|
|
|
const result = await buildPageElements(
|
|
createBaseOptions({
|
|
route,
|
|
routePath: "/search",
|
|
searchParams: new URLSearchParams(""),
|
|
}),
|
|
);
|
|
const record = result as Record<string, unknown>;
|
|
const pageElement = record["page:/search"];
|
|
expect(
|
|
React.isValidElement<{ searchParams?: Promise<Record<string, unknown>> }>(pageElement),
|
|
).toBe(true);
|
|
|
|
if (!React.isValidElement<{ searchParams?: Promise<Record<string, unknown>> }>(pageElement)) {
|
|
throw new Error("Expected page element");
|
|
}
|
|
|
|
expect(markDynamicUsageMock).not.toHaveBeenCalled();
|
|
expect(markRenderRequestApiUsageMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps loading-boundary render-tree searchParams status inspection inert", async () => {
|
|
const { searchParams } = await buildSearchPageSearchParams({ loadingBoundary: true });
|
|
|
|
Reflect.get(searchParams, "status");
|
|
|
|
expect(markDynamicUsageMock).not.toHaveBeenCalled();
|
|
expect(markRenderRequestApiUsageMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps non-loading render-tree searchParams await inert", async () => {
|
|
const { searchParams } = await buildSearchPageSearchParams();
|
|
|
|
await searchParams;
|
|
|
|
expect(markDynamicUsageMock).not.toHaveBeenCalled();
|
|
expect(markRenderRequestApiUsageMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("observes loading-boundary render-tree searchParams await as dynamic access", async () => {
|
|
const { searchParams } = await buildSearchPageSearchParams({ loadingBoundary: true });
|
|
|
|
await searchParams;
|
|
|
|
expect(markDynamicUsageMock).toHaveBeenCalled();
|
|
expect(markRenderRequestApiUsageMock).toHaveBeenCalledWith("searchParams");
|
|
});
|
|
|
|
it("keeps a cached primary page query-inert through the React render path", async () => {
|
|
await resetUseCacheRuntime();
|
|
const { registerCachedFunction } =
|
|
await import("../packages/vinext/src/shims/cache-runtime.js");
|
|
|
|
let pageCalls = 0;
|
|
const CachedPage = registerCachedFunction(
|
|
async ({ params }: { params: Promise<{ slug: string }> }): Promise<string> => {
|
|
pageCalls++;
|
|
const resolvedParams = await params;
|
|
return `primary:${resolvedParams.slug}`;
|
|
},
|
|
"/fixture/app/cached/page.tsx:default",
|
|
"",
|
|
{ appPageDefaultExport: true },
|
|
);
|
|
const route = createSyntheticRoute({
|
|
page: createSyntheticPageModule(CachedPage),
|
|
loading: { default: () => null },
|
|
layouts: [],
|
|
routeSegments: ["cached"],
|
|
pattern: "/cached",
|
|
});
|
|
|
|
await expectCachedRenderIgnoresQuery({
|
|
expectedText: "primary:same",
|
|
getCallCount: () => pageCalls,
|
|
render: (query) => buildAndRenderElement(route, "page:/cached", query),
|
|
});
|
|
});
|
|
|
|
it("keeps a cached active slot page query-inert through the React render path", async () => {
|
|
await resetUseCacheRuntime();
|
|
const { registerCachedFunction } =
|
|
await import("../packages/vinext/src/shims/cache-runtime.js");
|
|
|
|
function MainPage(): React.ReactNode {
|
|
return React.createElement("div", null, "main");
|
|
}
|
|
|
|
let slotCalls = 0;
|
|
const CachedSlotPage = registerCachedFunction(
|
|
async ({ params }: { params: Promise<Record<string, unknown>> }): Promise<string> => {
|
|
slotCalls++;
|
|
await params;
|
|
return "slot:cached";
|
|
},
|
|
"/fixture/app/cached/@modal/page.tsx:default",
|
|
"",
|
|
{ appPageDefaultExport: true },
|
|
);
|
|
const route = createSyntheticRoute({
|
|
page: createSyntheticPageModule(MainPage),
|
|
loading: { default: () => null },
|
|
layouts: [],
|
|
routeSegments: ["cached"],
|
|
pattern: "/cached",
|
|
slots: {
|
|
"@modal": {
|
|
name: "modal",
|
|
page: createSyntheticPageModule(CachedSlotPage),
|
|
layoutIndex: -1,
|
|
routeSegments: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
await expectCachedRenderIgnoresQuery({
|
|
expectedText: "slot:cached",
|
|
getCallCount: () => slotCalls,
|
|
render: (query) => buildAndRenderElement(route, "slot:modal:/", query),
|
|
});
|
|
});
|
|
|
|
it("keeps a cached intercepting slot page query-inert through the React render path", async () => {
|
|
await resetUseCacheRuntime();
|
|
const { registerCachedFunction } =
|
|
await import("../packages/vinext/src/shims/cache-runtime.js");
|
|
|
|
function MainPage(): React.ReactNode {
|
|
return React.createElement("div", null, "main");
|
|
}
|
|
|
|
let interceptCalls = 0;
|
|
const CachedInterceptPage = registerCachedFunction(
|
|
async ({ params }: { params: Promise<Record<string, unknown>> }): Promise<string> => {
|
|
interceptCalls++;
|
|
await params;
|
|
return "intercept:cached";
|
|
},
|
|
"/fixture/app/cached/@modal/(.)photo/page.tsx:default",
|
|
"",
|
|
{ appPageDefaultExport: true },
|
|
);
|
|
const route = createSyntheticRoute({
|
|
page: createSyntheticPageModule(MainPage),
|
|
loading: { default: () => null },
|
|
layouts: [],
|
|
routeSegments: ["cached"],
|
|
pattern: "/cached",
|
|
slots: {
|
|
"@modal": {
|
|
name: "modal",
|
|
default: createSyntheticPageModule(() => null),
|
|
layoutIndex: -1,
|
|
routeSegments: [],
|
|
},
|
|
},
|
|
});
|
|
const renderIntercept = async (query: string): Promise<string> => {
|
|
const result = await buildPageElements(
|
|
createBaseOptions({
|
|
route,
|
|
routePath: "/cached",
|
|
searchParams: new URLSearchParams({ q: query }),
|
|
opts: {
|
|
interceptSlotKey: "@modal",
|
|
interceptPage: createSyntheticPageModule(CachedInterceptPage),
|
|
},
|
|
}),
|
|
);
|
|
const record = result as Record<string, unknown>;
|
|
const element = record["slot:modal:/"];
|
|
if (!React.isValidElement(element)) {
|
|
throw new Error("Expected intercepting slot element");
|
|
}
|
|
|
|
markDynamicUsageMock.mockClear();
|
|
markRenderRequestApiUsageMock.mockClear();
|
|
return renderNode(element);
|
|
};
|
|
|
|
await expectCachedRenderIgnoresQuery({
|
|
expectedText: "intercept:cached",
|
|
getCallCount: () => interceptCalls,
|
|
render: renderIntercept,
|
|
});
|
|
});
|
|
|
|
it("does NOT call markDynamicUsage just because the request query has content", async () => {
|
|
function NoSearchPage(): React.ReactNode {
|
|
return React.createElement("div", null, "No Search");
|
|
}
|
|
|
|
const route = createSyntheticRoute({
|
|
page: createSyntheticPageModule(NoSearchPage),
|
|
layouts: [],
|
|
routeSegments: ["no-search"],
|
|
pattern: "/no-search",
|
|
});
|
|
|
|
await buildPageElements(
|
|
createBaseOptions({
|
|
route,
|
|
routePath: "/no-search",
|
|
searchParams: new URLSearchParams("q=test"),
|
|
}),
|
|
);
|
|
|
|
expect(markDynamicUsageMock).not.toHaveBeenCalled();
|
|
expect(markRenderRequestApiUsageMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("passes slot overrides when interception opts have a slot key and page", async () => {
|
|
function InterceptPage(): React.ReactNode {
|
|
return React.createElement("div", null, "Intercepted");
|
|
}
|
|
|
|
const route = createSyntheticRoute({
|
|
page: createSyntheticPageModule(() => React.createElement("div", null, "Main")),
|
|
layouts: [],
|
|
routeSegments: ["feed"],
|
|
pattern: "/feed",
|
|
});
|
|
|
|
const result = await buildPageElements(
|
|
createBaseOptions({
|
|
route,
|
|
routePath: "/feed",
|
|
opts: {
|
|
interceptSlotKey: "modal",
|
|
interceptPage: createSyntheticPageModule(InterceptPage),
|
|
interceptLayouts: [
|
|
createSyntheticPageModule(() => React.createElement("div", null, "Layout")),
|
|
],
|
|
interceptParams: { id: "123" },
|
|
} as Record<string, unknown>,
|
|
}),
|
|
);
|
|
|
|
const record = result as Record<string, unknown>;
|
|
expect(record[APP_ROUTE_KEY]).toBe("route:/feed");
|
|
expect(Object.prototype.hasOwnProperty.call(record, "page:/feed")).toBe(true);
|
|
});
|
|
|
|
it("builds elements for a page that receives search params", async () => {
|
|
function ParamPage(): React.ReactNode {
|
|
return React.createElement("div", null, "Params");
|
|
}
|
|
|
|
const route = createSyntheticRoute({
|
|
page: createSyntheticPageModule(ParamPage),
|
|
layouts: [],
|
|
routeSegments: ["user", "[id]"],
|
|
pattern: "/user/[id]",
|
|
});
|
|
|
|
const params: AppPageParams = { id: "42" };
|
|
|
|
const result = await buildPageElements(
|
|
createBaseOptions({
|
|
route,
|
|
routePath: "/user/[id]",
|
|
params,
|
|
searchParams: new URLSearchParams("ref=source"),
|
|
}),
|
|
);
|
|
|
|
const record = result as Record<string, unknown>;
|
|
expect(record[APP_ROUTE_KEY]).toBe("route:/user/[id]");
|
|
expect(Object.prototype.hasOwnProperty.call(record, "page:/user/[id]")).toBe(true);
|
|
});
|
|
|
|
it("passes page searchParams to active slot pages", async () => {
|
|
function MainPage(): React.ReactNode {
|
|
return React.createElement("div", null, "main");
|
|
}
|
|
function SlotPage(): React.ReactNode {
|
|
return React.createElement("span", null, "slot");
|
|
}
|
|
|
|
const route = createSyntheticRoute({
|
|
page: createSyntheticPageModule(MainPage),
|
|
layouts: [],
|
|
routeSegments: ["feed"],
|
|
pattern: "/feed",
|
|
slots: {
|
|
"@modal": {
|
|
name: "modal",
|
|
page: createSyntheticPageModule(SlotPage),
|
|
layoutIndex: -1,
|
|
routeSegments: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = await buildPageElements(
|
|
createBaseOptions({
|
|
route,
|
|
routePath: "/feed",
|
|
searchParams: new URLSearchParams("search=hello"),
|
|
}),
|
|
);
|
|
|
|
const record = result as Record<string, unknown>;
|
|
const slotElement = record["slot:modal:/"] as React.ReactElement<{
|
|
searchParams: PromiseLike<Record<string, unknown>>;
|
|
}>;
|
|
expect(slotElement).toBeDefined();
|
|
await expect(slotElement.props.searchParams).resolves.toEqual({ search: "hello" });
|
|
});
|
|
|
|
it("passes page searchParams to intercepting slot pages", async () => {
|
|
function MainPage(): React.ReactNode {
|
|
return React.createElement("div", null, "main");
|
|
}
|
|
function InterceptPage(): React.ReactNode {
|
|
return React.createElement("span", null, "intercept");
|
|
}
|
|
|
|
const route = createSyntheticRoute({
|
|
page: createSyntheticPageModule(MainPage),
|
|
layouts: [],
|
|
routeSegments: ["feed"],
|
|
pattern: "/feed",
|
|
slots: {
|
|
"@modal": {
|
|
name: "modal",
|
|
default: createSyntheticPageModule(() => null),
|
|
layoutIndex: -1,
|
|
routeSegments: [],
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = await buildPageElements(
|
|
createBaseOptions({
|
|
route,
|
|
routePath: "/feed",
|
|
searchParams: new URLSearchParams("search=hello"),
|
|
opts: {
|
|
interceptSlotKey: "@modal",
|
|
interceptPage: createSyntheticPageModule(InterceptPage),
|
|
},
|
|
}),
|
|
);
|
|
|
|
const record = result as Record<string, unknown>;
|
|
const slotElement = record["slot:modal:/"] as React.ReactElement<{
|
|
searchParams: PromiseLike<Record<string, unknown>>;
|
|
}>;
|
|
expect(slotElement).toBeDefined();
|
|
await expect(slotElement.props.searchParams).resolves.toEqual({ search: "hello" });
|
|
});
|
|
|
|
it("extracts slot params from routePath, not request.url, so basePath does not break the match", async () => {
|
|
function MainPage(): React.ReactNode {
|
|
return React.createElement("div", null, "main");
|
|
}
|
|
function SlotPage(): React.ReactNode {
|
|
return React.createElement("span", null, "slot");
|
|
}
|
|
|
|
// Inherited slot whose mirrored sub-page has a dynamic marker (`:name`)
|
|
// with a different name than the route's (`:id`). The slotPatternParts
|
|
// are app-relative — basePath was already stripped during graph build.
|
|
const route = createSyntheticRoute({
|
|
page: createSyntheticPageModule(MainPage),
|
|
layouts: [],
|
|
routeSegments: ["distinct", "[id]"],
|
|
pattern: "/distinct/[id]",
|
|
params: ["id"],
|
|
slots: {
|
|
"@bc": {
|
|
name: "bc",
|
|
page: createSyntheticPageModule(SlotPage),
|
|
layoutIndex: -1,
|
|
routeSegments: ["distinct", "[id]"],
|
|
slotPatternParts: ["distinct", ":name"],
|
|
slotParamNames: ["name"],
|
|
},
|
|
},
|
|
});
|
|
|
|
// Simulate a basePath-configured app: request URL still carries `/base`,
|
|
// but the entry passes the cleaned pathname as routePath. The fix must
|
|
// match against routePath, not against `new URL(request.url).pathname`.
|
|
const result = await buildPageElements({
|
|
...createBaseOptions({
|
|
route,
|
|
params: { id: "alice" },
|
|
routePath: "/distinct/alice",
|
|
}),
|
|
pageRequest: {
|
|
opts: null,
|
|
searchParams: null,
|
|
isRscRequest: false,
|
|
request: new Request("http://localhost/base/distinct/alice"),
|
|
mountedSlotsHeader: null,
|
|
},
|
|
});
|
|
|
|
const record = result as Record<string, unknown>;
|
|
const slotElement = record["slot:bc:/"] as React.ReactElement<{
|
|
params: PromiseLike<AppPageParams>;
|
|
}>;
|
|
expect(slotElement).toBeDefined();
|
|
const slotParams = await slotElement.props.params;
|
|
// Without the fix, urlParts would be ["base","distinct","alice"], the
|
|
// pattern match would fail, and slotParams would silently fall back to
|
|
// the route's matched params ({ id: "alice" }) — leaving the slot
|
|
// page's `name` undefined. With the fix, the slot gets its own params.
|
|
expect(slotParams).toEqual({ name: "alice" });
|
|
});
|
|
|
|
it("makeThenableParams wraps params as a proxy supporting both Promise and property access", () => {
|
|
const plainParams: AppPageParams = { id: "99" };
|
|
const thenable = makeThenableParams(plainParams);
|
|
|
|
expect(typeof thenable.then).toBe("function");
|
|
expect(Reflect.get(thenable as object, "id")).toBe("99");
|
|
|
|
return thenable.then((resolved: AppPageParams) => {
|
|
expect(resolved).toEqual(plainParams);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("probeAppPage", () => {
|
|
beforeEach(() => {
|
|
markDynamicUsageMock.mockClear();
|
|
markRenderRequestApiUsageMock.mockClear();
|
|
});
|
|
|
|
it("calls markDynamicUsage when the page awaits searchParams, even when the query is empty", async () => {
|
|
async function SearchPage({
|
|
searchParams,
|
|
}: {
|
|
searchParams: Promise<Record<string, unknown>>;
|
|
}): Promise<React.ReactNode> {
|
|
await searchParams;
|
|
return React.createElement("div", null, "Search");
|
|
}
|
|
|
|
await Promise.resolve(
|
|
probeAppPage({
|
|
asyncRouteParams: makeThenableParams({}),
|
|
pageComponent: SearchPage,
|
|
searchParams: new URLSearchParams(""),
|
|
}),
|
|
);
|
|
|
|
expect(markDynamicUsageMock).toHaveBeenCalled();
|
|
expect(markRenderRequestApiUsageMock).toHaveBeenCalledWith("searchParams");
|
|
});
|
|
|
|
it("calls markDynamicUsage when a returned server component consumes spread searchParams", async () => {
|
|
async function Child({
|
|
searchParams,
|
|
}: {
|
|
searchParams: Promise<Record<string, unknown>>;
|
|
}): Promise<React.ReactNode> {
|
|
await searchParams;
|
|
return React.createElement("div", null, "Child");
|
|
}
|
|
|
|
function SearchPage(props: {
|
|
searchParams: Promise<Record<string, unknown>>;
|
|
}): React.ReactNode {
|
|
return React.createElement(Child, props);
|
|
}
|
|
|
|
await Promise.resolve(
|
|
probeAppPage({
|
|
asyncRouteParams: makeThenableParams({}),
|
|
pageComponent: SearchPage,
|
|
searchParams: new URLSearchParams("q=test"),
|
|
}),
|
|
);
|
|
|
|
expect(markDynamicUsageMock).toHaveBeenCalled();
|
|
expect(markRenderRequestApiUsageMock).toHaveBeenCalledWith("searchParams");
|
|
});
|
|
|
|
it("does NOT call markDynamicUsage just because the request query has content", () => {
|
|
function NoSearchPage(): React.ReactNode {
|
|
return React.createElement("div", null, "No Search");
|
|
}
|
|
|
|
probeAppPage({
|
|
asyncRouteParams: makeThenableParams({}),
|
|
pageComponent: NoSearchPage,
|
|
searchParams: new URLSearchParams("q=test"),
|
|
});
|
|
|
|
expect(markDynamicUsageMock).not.toHaveBeenCalled();
|
|
expect(markRenderRequestApiUsageMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|