Files
cloudflare__vinext/tests/app-page-route-wiring.test.ts
T
Nathan Nguyen 10b5086f4d fix(app-router): align router autoscroll with Next (#2004)
* fix(app-router): align router autoscroll with Next

App Router navigation could diverge from Next.js router autoscroll semantics around hydration timing, loading-shell same-page search navigations, and routes whose first committed node is React-hoisted into head. That broke the upstream router-autoscroll deploy-suite because the client marked hydration too early, static loading-shell templates were not usable for navigation, and the fallback scroll path masked the old-handler hoisted-head behavior.

The fix moves the Next-compatible hydration marker into a passive effect, wires production hydrateRoot error callbacks to match Next's implicit root-boundary and recoverable-error handling, lets authoritative loading-shell templates drive static route optimistic payloads, and skips fallback document scrolling when the committed document only exposes a React-hoisted head resource. The ported tests cover the upstream router-autoscroll behavior plus focused unit boundaries for prefetch mode, optimistic routing, head-resource detection, and hydrateRoot callbacks.

* fix(app-router): hoisted-head scroll fallback is per-intent, not global head scan

* test(app-router): add full-chain scroll intent integration test

* fix(app-router): match legacy scroll focus parity

* fix(app-router): preserve latest scroll navigation intent

* fix(app-router): scope scroll and error recovery

* fix(app-router): gate navigation failure recovery

* fix(app-router): match navigation failure handling

* fix(app-router): align scroll completion parity

* fix(app-router): retain latest failure target

* fix(app-router): preserve route error boundaries

* fix(app-router): clear discarded failure targets

* fix(app-router): align failure recovery boundaries

* fix(app-router): disarm refused recovery targets

* fix(app-router): preserve failure target ownership

* fix(app-router): match boundary recovery parity

---------

Co-authored-by: James <james@eli.cx>
2026-06-14 23:59:28 +01:00

1818 lines
57 KiB
TypeScript

import { Fragment, createElement, isValidElement, type ReactElement, type ReactNode } from "react";
import { describe, expect, it } from "vite-plus/test";
import { useSelectedLayoutSegments } from "../packages/vinext/src/shims/navigation.js";
import {
APP_PREFETCH_LOADING_SHELL_MARKER_KEY,
APP_SOURCE_PAGE_KEY,
AppElementsWire,
APP_SLOT_BINDINGS_KEY,
APP_UNMATCHED_SLOT_WIRE_VALUE,
buildOutgoingAppPayload,
isAppElementsRecord,
type AppElements,
} from "../packages/vinext/src/server/app-elements.js";
import type { AppPageParams } from "../packages/vinext/src/server/app-page-boundary.js";
import {
type AppPageModule,
type AppPageSlotOverride,
buildAppPageElements,
createAppPageLayoutEntries,
probeAppPageLayoutWithTracking,
resolveAppPageChildSegments,
} from "../packages/vinext/src/server/app-page-route-wiring.js";
import { createAppLayoutParamAccessTracker } from "../packages/vinext/src/server/app-layout-param-observation.js";
import {
APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL,
APP_RSC_RENDER_MODE_REFRESH_PRESERVE_UI,
} from "../packages/vinext/src/server/app-rsc-render-mode.js";
import { makeThenableParams } from "../packages/vinext/src/shims/thenable-params.js";
import {
createRequestContext,
getRequestContext,
runWithRequestContext,
} from "../packages/vinext/src/shims/unified-request-context.js";
import { buildPageElements as buildResolvedPageElements } from "../packages/vinext/src/server/app-page-element-builder.js";
function readNode(value: unknown): string {
return typeof value === "string" ? value : "";
}
function readChildren(value: unknown): ReactNode {
if (
value === null ||
value === undefined ||
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
) {
return value;
}
if (Array.isArray(value)) {
return value.map((item) => readChildren(item));
}
if (isValidElement(value)) {
return value;
}
return null;
}
function containsElementType(node: unknown, type: unknown): boolean {
if (Array.isArray(node)) {
return node.some((child) => containsElementType(child, type));
}
if (!isValidElement<{ children?: unknown; fallback?: unknown }>(node)) {
return false;
}
return (
node.type === type ||
containsElementType(node.props.children, type) ||
containsElementType(node.props.fallback, type)
);
}
function getElementTypeName(type: unknown): string {
if (typeof type === "string") return type;
if (typeof type === "function") {
return (
(type as { displayName?: string; name?: string }).displayName ??
(type as { name?: string }).name ??
""
);
}
return String(type);
}
type InspectableElementProps = Record<string, unknown> & {
children?: unknown;
fallback?: unknown;
id?: unknown;
};
function findElement(
node: unknown,
predicate: (element: ReactElement<InspectableElementProps>) => boolean,
): ReactElement<InspectableElementProps> | null {
if (Array.isArray(node)) {
for (const child of node) {
const match = findElement(child, predicate);
if (match) return match;
}
return null;
}
if (!isValidElement<InspectableElementProps>(node)) {
return null;
}
if (predicate(node)) return node;
for (const value of Object.values(node.props)) {
const match = findElement(value, predicate);
if (match) return match;
}
return null;
}
function findElementByTypeName(
node: unknown,
typeName: string,
): ReactElement<Record<string, unknown>> | null {
const match = findElement(node, (element) => getElementTypeName(element.type) === typeName);
return match as ReactElement<Record<string, unknown>> | null;
}
function findSlotById(node: unknown, id: string): ReactElement<Record<string, unknown>> | null {
const match = findElement(
node,
(element) =>
getElementTypeName(element.type) === "Slot" &&
typeof element.props.id === "string" &&
element.props.id === id,
);
return match as ReactElement<Record<string, unknown>> | null;
}
function findSuspenseWithFallback(
node: unknown,
fallbackTypeName: string,
): ReactElement<Record<string, unknown>> | null {
const match = findElement(node, (element) => {
if (getElementTypeName(element.type) !== "Symbol(react.suspense)") {
return false;
}
const fallback = element.props.fallback;
return isValidElement(fallback) && getElementTypeName(fallback.type) === fallbackTypeName;
});
return match as ReactElement<Record<string, unknown>> | null;
}
async function readStream(stream: ReadableStream<Uint8Array>): Promise<string> {
const reader = stream.getReader();
const decoder = new TextDecoder();
let text = "";
for (;;) {
const { done, value } = await reader.read();
if (done) {
break;
}
text += decoder.decode(value, { stream: true });
}
return text + decoder.decode();
}
async function renderHtml(node: 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 readStream(stream);
}
async function renderRouteEntry(elements: AppElements, routeId: string): Promise<string> {
const { ElementsContext, Slot } = await import("../packages/vinext/src/shims/slot.js");
return renderHtml(
createElement(
ElementsContext.Provider,
{ value: elements },
createElement(Slot, { id: routeId }),
),
);
}
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timeoutId = setTimeout(() => {
reject(new Error(`Timed out after ${timeoutMs}ms`));
}, timeoutMs);
promise.then(
(value) => {
clearTimeout(timeoutId);
resolve(value);
},
(error: unknown) => {
clearTimeout(timeoutId);
reject(error);
},
);
});
}
function RootLayout(props: Record<string, unknown>) {
const segments = useSelectedLayoutSegments();
const sidebarSegments = useSelectedLayoutSegments("sidebar");
return createElement(
"div",
{
"data-layout": "root",
"data-segments": segments.join("|"),
"data-sidebar-segments": sidebarSegments.join("|"),
},
createElement("aside", { "data-slot": "sidebar" }, readChildren(props.sidebar)),
readChildren(props.children),
);
}
function GroupLayout(props: Record<string, unknown>) {
const segments = useSelectedLayoutSegments();
return createElement(
"section",
{
"data-layout": "group",
"data-segments": segments.join("|"),
},
readChildren(props.children),
);
}
function SlotLayout(props: Record<string, unknown>) {
return createElement("div", { "data-slot-layout": "sidebar" }, readChildren(props.children));
}
function InterceptOuterLayout(props: Record<string, unknown>) {
return createElement("div", { "data-intercept-layout": "outer" }, readChildren(props.children));
}
function InterceptInnerLayout(props: Record<string, unknown>) {
return createElement("div", { "data-intercept-layout": "inner" }, readChildren(props.children));
}
function SlotPage(props: Record<string, unknown>) {
return createElement("p", { "data-slot-page": readNode(props.label) }, readNode(props.label));
}
function ParentModalLayout(props: Record<string, unknown>) {
return createElement(
"div",
{ "data-layout": "parent-modal-layout" },
createElement("div", { "data-parent-modal": "true" }, readChildren(props.modal)),
readChildren(props.children),
);
}
function ChildModalLayout(props: Record<string, unknown>) {
return createElement(
"section",
{ "data-layout": "child-modal-layout" },
createElement("div", { "data-child-modal": "true" }, readChildren(props.modal)),
readChildren(props.children),
);
}
function RootTemplate(props: Record<string, unknown>) {
return createElement("div", { "data-template": "root" }, readChildren(props.children));
}
function GroupTemplate(props: Record<string, unknown>) {
return createElement("div", { "data-template": "group" }, readChildren(props.children));
}
function PageProbe() {
const segments = useSelectedLayoutSegments();
return createElement("main", { "data-page-segments": segments.join("|") }, "Page");
}
async function buildGeneratedMetadataRouteHtml(
userAgent: string,
htmlLimitedBots?: string,
): Promise<string> {
const elements = await buildResolvedPageElements({
route: {
error: null,
errors: [null],
layoutTreePositions: [0],
layouts: [{ default: RootLayout }],
loading: null,
notFound: null,
notFounds: [null],
page: {
default: PageProbe,
async generateMetadata() {
return { title: "generated page" };
},
},
params: [],
pattern: "/generated",
routeSegments: ["generated"],
slots: {},
templateTreePositions: [],
templates: [],
},
params: {},
routePath: "/generated",
pageRequest: {
isRscRequest: false,
mountedSlotsHeader: null,
request: new Request("http://localhost/generated", {
headers: { "user-agent": userAgent },
}),
searchParams: null,
},
metadataRoutes: [],
htmlLimitedBots,
});
return renderRouteEntry(elements, "route:/generated");
}
function RouteLoadingProbe() {
return createElement("p", null, "Route loading");
}
function SlotLoadingProbe() {
return createElement("p", null, "Slot loading");
}
function LayoutWithoutChildren() {
return createElement("div", { "data-layout": "without-children" }, "Layout only");
}
describe("app page route wiring helpers", () => {
it("probes returned layout children with param and revalidate tracking", async () => {
const calls: string[] = [];
const layoutParamAccess = createAppLayoutParamAccessTracker();
function Child() {
calls.push("child");
return null;
}
function Layout() {
calls.push("layout");
return createElement("section", null, createElement(Child));
}
await probeAppPageLayoutWithTracking({
layoutIndex: 0,
layoutParamAccess,
makeThenableParams,
matchedParams: {},
route: {
layoutTreePositions: [0],
layouts: [{ default: Layout, revalidate: 60 }],
routeSegments: ["dashboard"],
},
});
expect(calls).toEqual(["layout", "child"]);
expect(layoutParamAccess.getLayoutObservation("layout:/")).toMatchObject({
completeness: "complete",
finiteRevalidateSeconds: 60,
});
});
it("probes layout branches that render only when children are present", async () => {
const calls: string[] = [];
const layoutParamAccess = createAppLayoutParamAccessTracker();
function ChromeThatUsesTaggedData() {
calls.push("chrome");
getRequestContext().currentRequestTags.push("tag:dashboard-chrome");
return null;
}
function Layout(props: { children?: ReactNode }) {
calls.push("layout");
if (!props.children) return null;
return createElement(
"section",
null,
createElement(ChromeThatUsesTaggedData),
props.children,
);
}
await runWithRequestContext(createRequestContext(), () =>
probeAppPageLayoutWithTracking({
layoutIndex: 0,
layoutParamAccess,
makeThenableParams,
matchedParams: {},
route: {
layoutTreePositions: [0],
layouts: [{ default: Layout }],
routeSegments: ["dashboard"],
},
}),
);
expect(calls).toEqual(["layout", "chrome"]);
expect(layoutParamAccess.getLayoutObservation("layout:/")).toMatchObject({
cacheTags: ["tag:dashboard-chrome"],
completeness: "complete",
});
});
it("renders generated metadata in a hidden body outlet for streaming-capable requests", async () => {
// Ported from Next.js: test/e2e/app-dir/metadata-streaming/metadata-streaming.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/metadata-streaming/metadata-streaming.test.ts
const html = await buildGeneratedMetadataRouteHtml("HeadlessChrome");
expect(html).not.toContain("<title>generated page</title><div");
expect(html).toContain('<div hidden=""><title>generated page</title></div>');
});
it("renders generated metadata in the head for configured html-limited bots", async () => {
// Ported from Next.js: test/e2e/app-dir/metadata-streaming/metadata-streaming-customized-rule.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/metadata-streaming/metadata-streaming-customized-rule.test.ts
const html = await buildGeneratedMetadataRouteHtml("Minibot", "Minibot");
expect(html).toContain("<title>generated page</title>");
expect(html).not.toContain('<div hidden=""><title>generated page</title></div>');
});
it("renders generated metadata in the head for default html-limited bots", async () => {
// Ported from Next.js: test/e2e/app-dir/metadata-streaming/metadata-streaming.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/metadata-streaming/metadata-streaming.test.ts
const html = await buildGeneratedMetadataRouteHtml("Twitterbot");
expect(html).toContain("<title>generated page</title>");
expect(html).not.toContain('<div hidden=""><title>generated page</title></div>');
});
it("falls back to the default html-limited bot list for an empty config string", async () => {
// Next.js normalizes a falsy htmlLimitedBots config to the default bot regex.
// https://github.com/vercel/next.js/blob/canary/packages/next/src/server/lib/streaming-metadata.ts
const html = await buildGeneratedMetadataRouteHtml("HeadlessChrome", "");
expect(html).not.toContain("<title>generated page</title><div");
expect(html).toContain('<div hidden=""><title>generated page</title></div>');
});
it("resolves child segments from tree positions and preserves route groups", () => {
expect(
resolveAppPageChildSegments(["(marketing)", "blog", "[slug]", "[...parts]"], 1, {
parts: ["a", "b"],
slug: "post",
}),
).toEqual(["blog", "post", "a/b"]);
});
it("builds layout entries from tree paths instead of visible URL segments", () => {
const entries = createAppPageLayoutEntries({
layouts: [{ default: RootLayout }, { default: GroupLayout }],
layoutTreePositions: [0, 1],
notFounds: [null, null],
routeSegments: ["(marketing)", "blog", "[slug]"],
});
expect(entries.map((entry) => entry.id)).toEqual(["layout:/", "layout:/(marketing)"]);
expect(entries.map((entry) => entry.treePath)).toEqual(["/", "/(marketing)"]);
});
it("passes only segment-applicable params to each layout", () => {
// Ported from Next.js: test/e2e/app-dir/app/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app/index.test.ts
const paramCalls: AppPageParams[] = [];
buildAppPageElements({
element: createElement(PageProbe),
makeThenableParams(params) {
paramCalls.push({ ...params });
return Promise.resolve(params);
},
matchedParams: { category: "books", id: "hello-world" },
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errors: [null, null, null],
layoutTreePositions: [1, 2, 3],
layouts: [{ default: RootLayout }, { default: GroupLayout }, { default: GroupLayout }],
loading: null,
notFound: null,
notFounds: [null, null, null],
routeSegments: ["dynamic", "[category]", "[id]"],
slots: null,
templateTreePositions: [],
templates: [],
},
routePath: "/dynamic/books/hello-world",
rootNotFoundModule: null,
});
expect(paramCalls).toEqual([
{},
{ category: "books" },
{ category: "books", id: "hello-world" },
]);
});
it("encodes the active app source page from route segments", () => {
// Ported from Next.js: test/e2e/app-dir/app/index.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app/index.test.ts
const cases: Array<{
routePath: string;
routeSegments: readonly string[];
sourcePage: string;
}> = [
{
routePath: "/dashboard",
routeSegments: ["dashboard"],
sourcePage: "/dashboard/page",
},
{
routePath: "/dynamic/category-1/id-2",
routeSegments: ["dynamic", "[category]", "[id]"],
sourcePage: "/dynamic/[category]/[id]/page",
},
{
routePath: "/dashboard/another",
routeSegments: ["(newroot)", "dashboard", "another"],
sourcePage: "/(newroot)/dashboard/another/page",
},
];
for (const { routePath, routeSegments, sourcePage } of cases) {
const elements = buildAppPageElements({
element: createElement(PageProbe),
makeThenableParams(params) {
return Promise.resolve(params);
},
matchedParams: {},
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errors: [],
layoutTreePositions: [],
layouts: [],
loading: null,
notFound: null,
notFounds: [],
routeSegments,
slots: null,
templateTreePositions: [],
templates: [],
},
routePath,
rootNotFoundModule: null,
});
expect(elements[APP_SOURCE_PAGE_KEY]).toBe(sourcePage);
expect(AppElementsWire.readMetadata(elements).sourcePage).toBe(sourcePage);
}
});
it("builds a flat elements map with route, layout, template, page, and slot entries", async () => {
const elements = buildAppPageElements({
element: createElement(PageProbe),
makeThenableParams(params) {
return Promise.resolve(params);
},
matchedParams: { slug: "post" },
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errors: [null, null],
layoutTreePositions: [0, 1],
layouts: [{ default: RootLayout }, { default: GroupLayout }],
loading: null,
notFound: null,
notFounds: [null, null],
routeSegments: ["(marketing)", "blog", "[slug]"],
slots: {
sidebar: {
default: null,
error: null,
layout: { default: SlotLayout },
layoutIndex: 0,
loading: null,
name: "sidebar",
page: { default: SlotPage },
routeSegments: ["members"],
},
},
templateTreePositions: [1],
templates: [{ default: GroupTemplate }],
},
routePath: "/blog/post",
rootNotFoundModule: null,
slotOverrides: {
sidebar: {
pageModule: { default: SlotPage },
params: { slug: "post" },
props: { label: "intercepted" },
},
},
});
expect(elements.__route).toBe("route:/blog/post");
expect(elements.__sourcePage).toBe("/(marketing)/blog/[slug]/page");
expect(elements.__layoutIds).toEqual(["layout:/", "layout:/(marketing)"]);
expect(elements.__rootLayout).toBe("/");
expect(elements["layout:/"]).toBeDefined();
expect(elements["layout:/(marketing)"]).toBeDefined();
expect(elements["template:/(marketing)"]).toBeDefined();
expect(elements["page:/blog/post"]).toBeDefined();
expect(elements["slot:sidebar:/"]).toBeDefined();
expect(elements["route:/blog/post"]).toBeDefined();
const html = await renderRouteEntry(elements, "route:/blog/post");
expect(html).toContain('data-layout="root"');
expect(html).toContain('data-layout="group"');
expect(html).toContain('data-template="group"');
const groupLayoutPos = html.indexOf('data-layout="group"');
const groupTemplatePos = html.indexOf('data-template="group"');
expect(groupLayoutPos).toBeLessThan(groupTemplatePos);
expect(html).toContain('data-slot-layout="sidebar"');
expect(html).toContain('data-slot-page="intercepted"');
expect(html).toContain('data-page-segments=""');
expect(html).toContain('data-segments="(marketing)|blog|post"');
expect(html).toContain('data-segments="blog|post"');
});
it("suppresses route and slot loading boundaries for refresh payloads", () => {
const elements = buildAppPageElements({
element: createElement(PageProbe),
makeThenableParams(params) {
return Promise.resolve(params);
},
matchedParams: {},
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errors: [null],
layoutTreePositions: [0],
layouts: [{ default: RootLayout }],
loading: { default: RouteLoadingProbe },
notFound: null,
notFounds: [null],
routeSegments: ["dashboard"],
slots: {
sidebar: {
default: null,
error: null,
layout: null,
layoutIndex: 0,
loading: { default: SlotLoadingProbe },
name: "sidebar",
page: { default: SlotPage },
routeSegments: [],
},
},
templateTreePositions: [],
templates: [],
},
routePath: "/dashboard",
rootNotFoundModule: null,
renderMode: APP_RSC_RENDER_MODE_REFRESH_PRESERVE_UI,
});
expect(containsElementType(elements["route:/dashboard"], RouteLoadingProbe)).toBe(false);
expect(containsElementType(elements["slot:sidebar:/"], SlotLoadingProbe)).toBe(false);
});
it("serializes route loading UI instead of page content for loading-shell prefetches", async () => {
const elements = buildAppPageElements({
element: createElement(PageProbe),
makeThenableParams(params) {
return Promise.resolve(params);
},
matchedParams: {},
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errors: [null],
layoutTreePositions: [0],
layouts: [{ default: RootLayout }],
loading: { default: RouteLoadingProbe },
notFound: null,
notFounds: [null],
routeSegments: ["dashboard"],
templateTreePositions: [],
templates: [],
},
routePath: "/dashboard",
rootNotFoundModule: null,
renderMode: APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL,
});
expect(elements["page:/dashboard"]).toBeNull();
expect(elements[APP_PREFETCH_LOADING_SHELL_MARKER_KEY]).toBe("LoadingBoundary");
const html = await renderRouteEntry(elements, "route:/dashboard");
expect(html).toContain("Route loading");
expect(html).not.toContain("Page");
});
it("does not render page content for loading-shell prefetches without a route loading boundary", async () => {
const elements = buildAppPageElements({
element: createElement(PageProbe),
makeThenableParams(params) {
return Promise.resolve(params);
},
matchedParams: {},
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errors: [null],
layoutTreePositions: [0],
layouts: [{ default: RootLayout }],
loading: null,
notFound: null,
notFounds: [null],
routeSegments: ["dashboard"],
templateTreePositions: [],
templates: [],
},
routePath: "/dashboard",
rootNotFoundModule: null,
renderMode: APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL,
});
expect(elements["page:/dashboard"]).toBeNull();
expect(elements[APP_PREFETCH_LOADING_SHELL_MARKER_KEY]).toBeUndefined();
const html = await renderRouteEntry(elements, "route:/dashboard");
expect(html).not.toContain("Page");
});
it("uses override params for slot segment maps when an override page is active", async () => {
const elements = buildAppPageElements({
element: createElement(PageProbe),
makeThenableParams(params) {
return Promise.resolve(params);
},
matchedParams: {},
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errors: [null],
layoutTreePositions: [0],
layouts: [{ default: RootLayout }],
loading: null,
notFound: null,
notFounds: [null],
routeSegments: ["dashboard"],
slots: {
sidebar: {
default: null,
error: null,
layout: null,
layoutIndex: 0,
loading: null,
name: "sidebar",
page: { default: SlotPage },
routeSegments: ["members", "[id]"],
},
},
templateTreePositions: [],
templates: [],
},
routePath: "/dashboard",
rootNotFoundModule: null,
slotOverrides: {
sidebar: {
pageModule: { default: SlotPage },
params: { id: "42" },
props: { label: "override" },
},
},
});
const html = await renderRouteEntry(elements, "route:/dashboard");
expect(html).toContain('data-slot-page="override"');
expect(html).toContain('data-sidebar-segments="members|42"');
});
it("wraps intercepted slot overrides with intercept layout modules inside the slot layout", async () => {
const sidebarOverride: AppPageSlotOverride<AppPageModule> = {
layoutModules: [{ default: InterceptOuterLayout }, { default: InterceptInnerLayout }],
pageModule: { default: SlotPage },
props: { label: "intercepted" },
};
const elements = buildAppPageElements({
element: createElement(PageProbe),
makeThenableParams(params) {
return Promise.resolve(params);
},
matchedParams: {},
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errors: [null],
layoutTreePositions: [0],
layouts: [{ default: RootLayout }],
loading: null,
notFound: null,
notFounds: [null],
routeSegments: ["dashboard"],
slots: {
sidebar: {
default: null,
error: null,
layout: { default: SlotLayout },
layoutIndex: 0,
loading: null,
name: "sidebar",
page: { default: SlotPage },
routeSegments: [],
},
},
templateTreePositions: [],
templates: [],
},
routePath: "/dashboard",
rootNotFoundModule: null,
slotOverrides: {
sidebar: sidebarOverride,
},
});
const html = await renderRouteEntry(elements, "route:/dashboard");
expect(html).toContain('data-slot-layout="sidebar"');
expect(html).toContain('data-intercept-layout="outer"');
expect(html).toContain('data-intercept-layout="inner"');
expect(html).toContain('data-slot-page="intercepted"');
const slotLayoutPos = html.indexOf('data-slot-layout="sidebar"');
const outerLayoutPos = html.indexOf('data-intercept-layout="outer"');
const innerLayoutPos = html.indexOf('data-intercept-layout="inner"');
const pagePos = html.indexOf('data-slot-page="intercepted"');
expect(slotLayoutPos).toBeLessThan(outerLayoutPos);
expect(outerLayoutPos).toBeLessThan(innerLayoutPos);
expect(innerLayoutPos).toBeLessThan(pagePos);
});
it("renders same-named slot props independently at different layout levels", async () => {
const elements = buildAppPageElements({
element: createElement(PageProbe),
makeThenableParams(params) {
return Promise.resolve(params);
},
matchedParams: {},
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errors: [null, null],
layoutTreePositions: [0, 1],
layouts: [{ default: ParentModalLayout }, { default: ChildModalLayout }],
loading: null,
notFound: null,
notFounds: [null, null],
routeSegments: ["parent", "child"],
slots: {
"modal@parent/@modal": {
default: {
default: () => createElement("p", { "data-parent-slot": "true" }, "parent-slot"),
},
error: null,
layout: null,
layoutIndex: 0,
loading: null,
name: "modal",
page: null,
routeSegments: null,
},
"modal@parent/child/@modal": {
default: {
default: () => createElement("p", { "data-child-slot": "true" }, "child-slot"),
},
error: null,
layout: null,
layoutIndex: 1,
loading: null,
name: "modal",
page: null,
routeSegments: null,
},
},
templateTreePositions: [0, 1],
templates: [null, null],
},
routePath: "/parent/child",
rootNotFoundModule: null,
});
const html = await renderRouteEntry(elements, "route:/parent/child");
expect(html).toContain('data-layout="parent-modal-layout"');
expect(html).toContain('data-layout="child-modal-layout"');
expect(html).toContain('data-parent-slot="true"');
expect(html).toContain("parent-slot");
expect(html).toContain('data-child-slot="true"');
expect(html).toContain("child-slot");
});
it("does not apply ambiguous name-only slot overrides when same-named slots exist", async () => {
const elements = buildAppPageElements({
element: createElement(PageProbe),
makeThenableParams(params) {
return Promise.resolve(params);
},
matchedParams: {},
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errors: [null, null],
layoutTreePositions: [0, 1],
layouts: [{ default: ParentModalLayout }, { default: ChildModalLayout }],
loading: null,
notFound: null,
notFounds: [null, null],
routeSegments: ["parent", "child"],
slots: {
"modal@parent/@modal": {
default: {
default: () => createElement("p", { "data-parent-slot": "true" }, "parent-slot"),
},
error: null,
layout: null,
layoutIndex: 0,
loading: null,
name: "modal",
page: null,
routeSegments: null,
},
"modal@parent/child/@modal": {
default: {
default: () => createElement("p", { "data-child-slot": "true" }, "child-slot"),
},
error: null,
layout: null,
layoutIndex: 1,
loading: null,
name: "modal",
page: null,
routeSegments: null,
},
},
templateTreePositions: [0, 1],
templates: [null, null],
},
routePath: "/parent/child",
rootNotFoundModule: null,
slotOverrides: {
modal: {
pageModule: { default: SlotPage },
props: { label: "ambiguous-override" },
},
},
});
const html = await renderRouteEntry(elements, "route:/parent/child");
expect(html).toContain('data-parent-slot="true"');
expect(html).toContain("parent-slot");
expect(html).toContain('data-child-slot="true"');
expect(html).toContain("child-slot");
expect(html).not.toContain('data-slot-page="ambiguous-override"');
});
it("omits slot key on RSC request when slot has only default.tsx (no page) and slot is already mounted", () => {
const DefaultPage = () => createElement("p", null, "default-slot");
const elements = buildAppPageElements({
isRscRequest: true,
mountedSlotIds: new Set(["slot:team:/"]),
element: createElement(PageProbe),
makeThenableParams(params) {
return Promise.resolve(params);
},
matchedParams: {},
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errors: [null],
layoutTreePositions: [0],
layouts: [{ default: RootLayout }],
loading: null,
notFound: null,
notFounds: [null],
routeSegments: [],
slots: {
team: {
default: { default: DefaultPage },
error: null,
layout: null,
layoutIndex: 0,
loading: null,
name: "team",
page: null,
routeSegments: [],
},
},
templateTreePositions: [],
templates: [],
},
routePath: "/",
rootNotFoundModule: null,
});
// On RSC soft nav, a slot with only default.tsx (no page) should have its
// key absent so the browser retains prior content — but only when the slot
// is already mounted (browser told us via X-Vinext-Mounted-Slots header).
expect(elements["slot:team:/"]).toBeUndefined();
});
it("renders slot default.tsx on RSC request when slot is not in mountedSlotIds (first entry)", () => {
const DefaultPage = () => createElement("p", null, "default-slot");
const elements = buildAppPageElements({
isRscRequest: true,
mountedSlotIds: new Set([]),
element: createElement(PageProbe),
makeThenableParams(params) {
return Promise.resolve(params);
},
matchedParams: {},
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errors: [null],
layoutTreePositions: [0],
layouts: [{ default: RootLayout }],
loading: null,
notFound: null,
notFounds: [null],
routeSegments: [],
slots: {
team: {
default: { default: DefaultPage },
error: null,
layout: null,
layoutIndex: 0,
loading: null,
name: "team",
page: null,
routeSegments: [],
},
},
templateTreePositions: [],
templates: [],
},
routePath: "/",
rootNotFoundModule: null,
});
// Even on an RSC request, when the slot has not been mounted on the client
// yet (first navigation into this layout), default.tsx must render so the
// initial slot content is populated.
expect(elements["slot:team:/"]).toBeDefined();
});
it("renders slot default.tsx on hard navigation when slot has no page", () => {
const DefaultPage = () => createElement("p", null, "default-slot");
const elements = buildAppPageElements({
isRscRequest: false,
element: createElement(PageProbe),
makeThenableParams(params) {
return Promise.resolve(params);
},
matchedParams: {},
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errors: [null],
layoutTreePositions: [0],
layouts: [{ default: RootLayout }],
loading: null,
notFound: null,
notFounds: [null],
routeSegments: [],
slots: {
team: {
default: { default: DefaultPage },
error: null,
layout: null,
layoutIndex: 0,
loading: null,
name: "team",
page: null,
routeSegments: [],
},
},
templateTreePositions: [],
templates: [],
},
routePath: "/",
rootNotFoundModule: null,
});
// On hard navigation the default.tsx must render so the initial HTML is
// fully populated.
expect(elements["slot:team:/"]).toBeDefined();
});
it.each([
{
label: "page module without default export",
slotModule: { default: null, page: {} },
},
{
label: "default module without default export",
slotModule: { default: {}, page: null },
},
])("marks slots unmatched when the effective $label is not renderable", ({ slotModule }) => {
const elements = buildAppPageElements({
element: createElement(PageProbe),
makeThenableParams(params) {
return Promise.resolve(params);
},
matchedParams: {},
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errors: [null],
layoutTreePositions: [0],
layouts: [{ default: RootLayout }],
loading: null,
notFound: null,
notFounds: [null],
routeSegments: [],
slots: {
team: {
default: slotModule.default,
error: null,
layout: null,
layoutIndex: 0,
loading: null,
name: "team",
page: slotModule.page,
routeSegments: [],
},
},
templateTreePositions: [],
templates: [],
},
routePath: "/",
rootNotFoundModule: null,
});
expect(elements[APP_SLOT_BINDINGS_KEY]).toEqual([
{
ownerLayoutId: "layout:/",
slotId: "slot:team:/",
state: "unmatched",
},
]);
expect(elements["slot:team:/"]).toBe(APP_UNMATCHED_SLOT_WIRE_VALUE);
});
it("does not deadlock when a layout renders without children", async () => {
const elements = buildAppPageElements({
element: createElement("main", null, "Page content"),
makeThenableParams(params) {
return Promise.resolve(params);
},
matchedParams: {},
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errors: [null],
layoutTreePositions: [0],
layouts: [{ default: LayoutWithoutChildren }],
loading: null,
notFound: null,
notFounds: [null],
routeSegments: [],
slots: null,
templateTreePositions: [],
templates: [],
},
routePath: "/layout-only",
rootNotFoundModule: null,
});
const body = await withTimeout(renderRouteEntry(elements, "route:/layout-only"), 1_000);
expect(body).toContain("Layout only");
expect(body).not.toContain("Page content");
});
it("preserves route subtree when a layout entry has no default export", async () => {
const elements = buildAppPageElements({
element: createElement("main", null, "Page content"),
makeThenableParams(params) {
return Promise.resolve(params);
},
matchedParams: {},
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errors: [null, null],
layoutTreePositions: [0, 1],
layouts: [{ default: RootLayout }, null],
loading: null,
notFound: null,
notFounds: [null, null],
routeSegments: ["dashboard"],
slots: null,
templateTreePositions: [],
templates: [],
},
routePath: "/dashboard",
rootNotFoundModule: null,
});
const body = await renderRouteEntry(elements, "route:/dashboard");
expect(body).toContain('data-layout="root"');
expect(body).toContain("Page content");
});
it("waits for template-only segments before serializing the page entry", async () => {
let activeLocale = "en";
async function AsyncTemplate(props: Record<string, unknown>) {
await Promise.resolve();
activeLocale = "de";
return createElement("div", { "data-template": "async" }, readChildren(props.children));
}
function LocalePage() {
return createElement("main", null, `page:${activeLocale}`);
}
const elements = buildAppPageElements({
element: createElement(LocalePage),
makeThenableParams(params) {
return Promise.resolve(params);
},
matchedParams: {},
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errors: [],
layoutTreePositions: [],
layouts: [],
loading: null,
notFound: null,
notFounds: [],
routeSegments: ["blog"],
slots: null,
templateTreePositions: [1],
templates: [{ default: AsyncTemplate }],
},
routePath: "/blog",
rootNotFoundModule: null,
});
const body = await renderHtml(
createElement(
Fragment,
null,
readChildren(elements["template:/blog"]),
readChildren(elements["page:/blog"]),
),
);
expect(body).toContain("page:de");
expect(body).not.toContain("page:en");
});
it("releases skipped layout dependencies before serializing retained child entries", async () => {
let activeLocale = "en";
async function StaticLayout(props: Record<string, unknown>) {
await Promise.resolve();
activeLocale = "de";
return createElement("div", { "data-layout": "static" }, readChildren(props.children));
}
function LocalePage() {
return createElement("main", null, `page:${activeLocale}`);
}
const elements = buildAppPageElements({
element: createElement(LocalePage),
makeThenableParams(params) {
return Promise.resolve(params);
},
matchedParams: {},
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errors: [null],
layoutTreePositions: [0],
layouts: [{ default: StaticLayout }],
loading: null,
notFound: null,
notFounds: [null],
routeSegments: ["skip-layout"],
slots: null,
templateTreePositions: [],
templates: [],
},
routePath: "/skip-layout",
rootNotFoundModule: null,
});
const payload = buildOutgoingAppPayload({
element: elements,
layoutFlags: { "layout:/": "s" },
skipDisposition: {
code: "SKIP_STATIC_LAYOUT_VERIFIED",
enabled: true,
mode: "skipStaticLayout",
skippedEntryIds: ["layout:/"],
},
});
expect(isAppElementsRecord(payload)).toBe(true);
if (!isAppElementsRecord(payload)) return;
expect(Object.hasOwn(payload, "layout:/")).toBe(false);
const body = await withTimeout(renderHtml(readChildren(payload["page:/skip-layout"])), 1_000);
expect(body).toContain("page:en");
});
it("renders template-only segments in the route entry even without a matching layout", async () => {
function BlogTemplate(props: Record<string, unknown>) {
return createElement("div", { "data-template": "blog" }, readChildren(props.children));
}
function BlogPage() {
return createElement("main", null, "Blog page");
}
const elements = buildAppPageElements({
element: createElement(BlogPage),
makeThenableParams(params) {
return Promise.resolve(params);
},
matchedParams: {},
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errors: [null],
layoutTreePositions: [0],
layouts: [{ default: RootLayout }],
loading: null,
notFound: null,
notFounds: [null],
routeSegments: ["blog"],
slots: null,
templateTreePositions: [1],
templates: [{ default: BlogTemplate }],
},
routePath: "/blog",
rootNotFoundModule: null,
});
const body = await renderRouteEntry(elements, "route:/blog");
expect(body).toContain('data-layout="root"');
expect(body).toContain('data-template="blog"');
expect(body).toContain("Blog page");
});
it("nests per-segment NotFoundBoundary inside the template wrapper", () => {
function RootNotFound() {
return createElement("div", { "data-not-found": "root" }, "Not Found");
}
function LeafPage() {
return createElement("main", null, "Page");
}
const elements = buildAppPageElements({
element: createElement(LeafPage),
makeThenableParams(params) {
return Promise.resolve(params);
},
matchedParams: {},
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errors: [null],
layoutTreePositions: [0],
layouts: [{ default: RootLayout }],
loading: null,
notFound: null,
notFounds: [{ default: RootNotFound }],
routeSegments: ["blog"],
slots: {},
templateTreePositions: [0],
templates: [{ default: RootTemplate }],
},
routePath: "/blog",
rootNotFoundModule: null,
});
function walkDepth(node: unknown, depth: number, found: Map<string, number>): void {
if (!isValidElement(node)) return;
const element = node as { type: unknown; props: Record<string, unknown> };
if (typeof element.props.id === "string" && element.props.id.startsWith("template:")) {
found.set(`template:${element.props.id}`, depth);
}
const typeName =
typeof element.type === "function"
? ((element.type as { displayName?: string; name?: string }).displayName ??
(element.type as { name?: string }).name ??
"")
: typeof element.type === "string"
? element.type
: "";
if (!found.has(typeName)) {
found.set(typeName, depth);
}
const { children, ...rest } = element.props;
for (const value of Object.values(rest)) {
walkDepth(value, depth + 1, found);
}
if (Array.isArray(children)) {
for (const child of children) {
walkDepth(child, depth + 1, found);
}
} else {
walkDepth(children, depth + 1, found);
}
}
const depthMap = new Map<string, number>();
walkDepth(elements["route:/blog"], 0, depthMap);
const templateDepth = depthMap.get("template:template:/");
const notFoundDepth = depthMap.get("NotFoundBoundaryInner") ?? depthMap.get("NotFoundBoundary");
expect(templateDepth).toBeDefined();
expect(notFoundDepth).toBeDefined();
expect(templateDepth).toBeLessThan(notFoundDepth!);
});
it("keys template slots with the semantic segment state key", () => {
function LeafTemplate(props: { children?: ReactNode }) {
return createElement("section", { "data-template": "leaf" }, readChildren(props.children));
}
const elements = buildAppPageElements({
element: createElement(PageProbe),
makeThenableParams(params) {
return Promise.resolve(params);
},
matchedParams: { slug: "launch" },
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errors: [null, null],
layoutTreePositions: [0, 1],
layouts: [{ default: RootLayout }, { default: GroupLayout }],
loading: null,
notFound: null,
notFounds: [null, null],
routeSegments: ["docs", "[slug]"],
slots: {},
templateTreePositions: [1],
templates: [{ default: LeafTemplate }],
},
routePath: "/docs/launch",
rootNotFoundModule: null,
});
const templateSlot = findSlotById(elements["route:/docs/launch"], "template:/docs");
expect(templateSlot).not.toBeNull();
expect(templateSlot?.key).toBe("slug|launch|d");
});
it("threads route state reset keys into loading, error, and not-found boundaries", () => {
function RouteLoading() {
return createElement("p", null, "Loading");
}
function RouteError() {
return createElement("p", null, "Error");
}
function RouteNotFound() {
return createElement("p", null, "Not Found");
}
const elements = buildAppPageElements({
element: createElement(PageProbe),
makeThenableParams(params) {
return Promise.resolve(params);
},
matchedParams: { id: "alpha" },
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: { default: RouteError },
errors: [null],
layoutTreePositions: [0],
layouts: [{ default: RootLayout }],
loading: { default: RouteLoading },
notFound: { default: RouteNotFound },
notFounds: [null],
routeSegments: ["products", "[id]"],
slots: {},
templateTreePositions: [],
templates: [],
},
routePath: "/products/alpha",
rootNotFoundModule: null,
});
const routeEntry = elements["route:/products/alpha"];
const loadingBoundary = findSuspenseWithFallback(routeEntry, "RouteLoading");
const errorBoundary = findElementByTypeName(routeEntry, "ErrorBoundary");
const notFoundBoundary = findElementByTypeName(routeEntry, "NotFoundBoundary");
expect(loadingBoundary?.key).toBe(JSON.stringify(["products", "id|alpha|d"]));
expect(errorBoundary?.props.resetKey).toBe(JSON.stringify(["products", "id|alpha|d"]));
expect(notFoundBoundary?.props.resetKey).toBe(JSON.stringify(["products", "id|alpha|d"]));
});
it("does not collide route reset keys across branches with the same dynamic leaf value", () => {
function RouteLoading() {
return createElement("p", null, "Loading");
}
function RouteError() {
return createElement("p", null, "Error");
}
function RouteNotFound() {
return createElement("p", null, "Not Found");
}
function buildBranchElements(branch: "posts" | "photos") {
return buildAppPageElements({
element: createElement(PageProbe),
makeThenableParams(params) {
return Promise.resolve(params);
},
matchedParams: { id: "123" },
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: { default: RouteError },
errors: [null],
layoutTreePositions: [0],
layouts: [{ default: RootLayout }],
loading: { default: RouteLoading },
notFound: { default: RouteNotFound },
notFounds: [null],
routeSegments: ["reset-collision", branch, "[id]"],
slots: {},
templateTreePositions: [],
templates: [],
},
routePath: `/reset-collision/${branch}/123`,
rootNotFoundModule: null,
})[`route:/reset-collision/${branch}/123`];
}
const postsRoute = buildBranchElements("posts");
const photosRoute = buildBranchElements("photos");
const postsLoadingBoundary = findSuspenseWithFallback(postsRoute, "RouteLoading");
const photosLoadingBoundary = findSuspenseWithFallback(photosRoute, "RouteLoading");
const postsErrorBoundary = findElementByTypeName(postsRoute, "ErrorBoundary");
const photosErrorBoundary = findElementByTypeName(photosRoute, "ErrorBoundary");
const postsNotFoundBoundary = findElementByTypeName(postsRoute, "NotFoundBoundary");
const photosNotFoundBoundary = findElementByTypeName(photosRoute, "NotFoundBoundary");
expect(postsLoadingBoundary?.key).toBe(
JSON.stringify(["reset-collision", "posts", "id|123|d"]),
);
expect(photosLoadingBoundary?.key).toBe(
JSON.stringify(["reset-collision", "photos", "id|123|d"]),
);
expect(postsLoadingBoundary?.key).not.toBe(photosLoadingBoundary?.key);
expect(postsErrorBoundary?.props.resetKey).not.toBe(photosErrorBoundary?.props.resetKey);
expect(postsNotFoundBoundary?.props.resetKey).not.toBe(photosNotFoundBoundary?.props.resetKey);
});
it("does not collide route reset keys across static branches with the same leaf segment", () => {
function RouteLoading() {
return createElement("p", null, "Loading");
}
function RouteError() {
return createElement("p", null, "Error");
}
function buildStaticBranchElements(branch: "account" | "admin") {
return buildAppPageElements({
element: createElement(PageProbe),
makeThenableParams(params) {
return Promise.resolve(params);
},
matchedParams: {},
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: { default: RouteError },
errors: [null],
layoutTreePositions: [0],
layouts: [{ default: RootLayout }],
loading: { default: RouteLoading },
notFound: null,
notFounds: [null],
routeSegments: ["reset-collision", branch, "settings"],
slots: {},
templateTreePositions: [],
templates: [],
},
routePath: `/reset-collision/${branch}/settings`,
rootNotFoundModule: null,
})[`route:/reset-collision/${branch}/settings`];
}
const accountRoute = buildStaticBranchElements("account");
const adminRoute = buildStaticBranchElements("admin");
const accountLoadingBoundary = findSuspenseWithFallback(accountRoute, "RouteLoading");
const adminLoadingBoundary = findSuspenseWithFallback(adminRoute, "RouteLoading");
const accountErrorBoundary = findElementByTypeName(accountRoute, "ErrorBoundary");
const adminErrorBoundary = findElementByTypeName(adminRoute, "ErrorBoundary");
expect(accountLoadingBoundary?.key).toBe(
JSON.stringify(["reset-collision", "account", "settings"]),
);
expect(adminLoadingBoundary?.key).toBe(
JSON.stringify(["reset-collision", "admin", "settings"]),
);
expect(accountLoadingBoundary?.key).not.toBe(adminLoadingBoundary?.key);
expect(accountErrorBoundary?.props.resetKey).not.toBe(adminErrorBoundary?.props.resetKey);
});
it("threads segment reset keys into boundaries even without template.tsx", () => {
function SegmentError() {
return createElement("p", null, "Segment Error");
}
const elements = buildAppPageElements({
element: createElement(PageProbe),
makeThenableParams(params) {
return Promise.resolve(params);
},
matchedParams: { slug: "intro" },
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errorPaths: [{ default: SegmentError }],
errors: [null],
errorTreePositions: [1],
layoutTreePositions: [0],
layouts: [{ default: RootLayout }],
loading: null,
notFound: null,
notFounds: [null],
routeSegments: ["docs", "[slug]"],
slots: {},
templateTreePositions: [],
templates: [],
},
routePath: "/docs/intro",
rootNotFoundModule: null,
});
const errorBoundary = findElementByTypeName(elements["route:/docs/intro"], "ErrorBoundary");
expect(errorBoundary?.props.resetKey).toBe("slug|intro|d");
});
it("nests user global errors inside the default global error fallback", () => {
function UserGlobalError() {
return createElement("p", null, "User global error");
}
const elements = buildAppPageElements({
element: createElement(PageProbe),
globalErrorModule: { default: UserGlobalError },
makeThenableParams(params) {
return Promise.resolve(params);
},
matchedParams: {},
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errors: [null],
layoutTreePositions: [0],
layouts: [{ default: RootLayout }],
loading: null,
notFound: null,
notFounds: [null],
routeSegments: [],
slots: {},
templateTreePositions: [],
templates: [],
},
routePath: "/",
rootNotFoundModule: null,
});
const routeEntry = elements["route:/"];
const outerBoundary = findElementByTypeName(routeEntry, "GlobalErrorBoundary");
const userBoundary = findElementByTypeName(outerBoundary?.props.children, "ErrorBoundary");
expect(getElementTypeName(outerBoundary?.props.fallback)).toBe("DefaultGlobalError");
expect(userBoundary?.props.fallback).toBe(UserGlobalError);
});
it("installs the default global error boundary without a user global error", () => {
const elements = buildAppPageElements({
element: createElement(PageProbe),
makeThenableParams: (params) => Promise.resolve(params),
matchedParams: {},
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errors: [null],
layoutTreePositions: [0],
layouts: [{ default: RootLayout }],
loading: null,
notFound: null,
notFounds: [null],
routeSegments: [],
slots: {},
templateTreePositions: [],
templates: [],
},
routePath: "/",
rootNotFoundModule: null,
});
const outerBoundary = findElementByTypeName(elements["route:/"], "GlobalErrorBoundary");
expect(getElementTypeName(outerBoundary?.props.fallback)).toBe("DefaultGlobalError");
});
it("interleaves templates with their corresponding layouts", async () => {
const elements = buildAppPageElements({
element: createElement(PageProbe),
makeThenableParams(params) {
return Promise.resolve(params);
},
matchedParams: { slug: "post" },
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errors: [null, null],
layoutTreePositions: [0, 1],
layouts: [{ default: RootLayout }, { default: GroupLayout }],
loading: null,
notFound: null,
notFounds: [null, null],
routeSegments: ["(marketing)", "blog", "[slug]"],
slots: {},
templateTreePositions: [0, 1],
templates: [{ default: RootTemplate }, { default: GroupTemplate }],
},
routePath: "/blog/post",
rootNotFoundModule: null,
});
const html = await renderRouteEntry(elements, "route:/blog/post");
expect(html).toContain('data-layout="root"');
expect(html).toContain('data-layout="group"');
expect(html).toContain('data-template="root"');
expect(html).toContain('data-template="group"');
const rootLayoutPos = html.indexOf('data-layout="root"');
const rootTemplatePos = html.indexOf('data-template="root"');
const groupLayoutPos = html.indexOf('data-layout="group"');
const groupTemplatePos = html.indexOf('data-template="group"');
const pagePos = html.indexOf("data-page-segments=");
expect(rootLayoutPos).toBeLessThan(rootTemplatePos);
expect(rootTemplatePos).toBeLessThan(groupLayoutPos);
expect(groupLayoutPos).toBeLessThan(groupTemplatePos);
expect(groupTemplatePos).toBeLessThan(pagePos);
});
});