Files
cloudflare__vinext/tests/app-page-element-builder.test.ts
T
Nathan Nguyen 656d954abf feat(router): promote intercepted preservation through planner (#1249)
* feat(router): promote intercepted preservation through planner

Intercepted navigation could still preserve source UI through context-bearing payload shape and same-URL history state. That made modal preservation depend on transport metadata instead of the visible route world that had actually been committed.

The planner now requires explicit interception proof before preserving source layouts or unrelated slots, and the browser lifecycle clears stale previous-next history state when normal target payloads commit. Wire metadata carries proof, while missing, stale, or incompatible proof falls back to hard navigation.

Tests cover approved source-slot preservation, missing, stale, and malformed proof rejection, current-context refresh and action handling, traverse restoration, and direct-target refresh clearing stale interception state.

* fix(router): normalize intercepted proof paths

Interception proof could be built from browser-derived percent-encoded paths while committed route snapshots used the server-normalized route-state form. Non-ASCII intercepted source or target paths could then fail proof validation and fall back to a hard navigation.

Normalize proof matched URLs before encoding source and target route IDs, and normalize client planner snapshot matched URLs to the same route-state representation without changing the user-facing navigation snapshot.

Adds regressions for encoded non-ASCII proof generation and planner validation.

* review: address ask-bonk PR review comments

- Add interceptedRejectedTargetMismatch reason code for target URL
  mismatch in planner traces (was misusing UnknownSource)
- Document double-attempt replaceState fallback for browser quirks
- Clarify navigate branches: proven interception vs legacy fallback
- Document cache migration safety for legacy interceptionContext
- Tighten isInterceptionMetadataValue type guard with string checks
- Extract interception attachment into shared step in wire metadata
  entries to remove asymmetric branching

* fix(router): ignore context-only interception metadata

Context-only AppElements payloads could enter intercepted preservation planning or leave context-suffixed route identity as later proof authority. That made transport metadata influence visible-world preservation when a normal response was rendered from an intercepted request context.

Only explicit interception proof now enters the preservation branch. Planner snapshots also canonicalize context-suffixed route IDs when no proof is present, keeping render keys partitioned while preventing stale context from poisoning future explicit interception proof.

Tests cover the direct planner decision and the two-step browser lifecycle regression.

* test(router): cover intercepted proof rejection matrix

Planner traces now expose distinct rejection reasons for mismatched intercepted targets and incompatible roots. Without direct tests, those branches could regress while the broader intercepted route scenarios still passed.

Add focused planner tests for target mismatch and incompatible root rejection, use the local target snapshot consistently, document two lifecycle ordering contracts, and avoid the no-op interception metadata spread on non-intercepted payloads.
2026-05-18 10:18:49 +01:00

545 lines
18 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_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 the function under test AFTER mocking dependencies.
// eslint-disable-next-line import/first
import { buildPageElements } from "../packages/vinext/src/server/app-page-element-builder.js";
import type { AppPageBuildRoute } from "../packages/vinext/src/server/app-page-element-builder.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,
}));
// ---------------------------------------------------------------------------
// 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: [],
};
}
// ---------------------------------------------------------------------------
// 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 interception context in 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\u0000ctx-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",
},
{
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([
{
ownerLayoutId: "layout:/feed",
slotId: "slot:modal:/feed",
state: "active",
},
]);
});
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("calls markDynamicUsage when search params have content", async () => {
function SearchPage(): React.ReactNode {
return React.createElement("div", null, "Search");
}
const route = createSyntheticRoute({
page: createSyntheticPageModule(SearchPage),
layouts: [],
routeSegments: ["search"],
pattern: "/search",
});
await buildPageElements(
createBaseOptions({
route,
routePath: "/search",
searchParams: new URLSearchParams("q=test"),
}),
);
expect(markDynamicUsageMock).toHaveBeenCalled();
expect(markRenderRequestApiUsageMock).toHaveBeenCalledWith("searchParams");
});
it("does NOT call markDynamicUsage when search params are empty", 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(""),
}),
);
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("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);
});
});
});