Files
cloudflare__vinext/tests/app-page-route-wiring.test.ts
James Anderson c296ad34db fix(app-router): retain shared layouts across loading shells (#2940)
* test(app-router): reproduce shared layout teardown on soft navigation

Add a `/layout-identity` fixture to the app-router-cloudflare example and
e2e coverage showing that a shared `[slug]` layout is discarded during a
soft navigation that only changes segments below it.

The fixture pairs the shared layout with a `loading.tsx` boundary above
it, a parallel slot beside `children`, and a nested dynamic segment with
its own layout, matching the shape a real app has. Removing the
`loading.tsx` boundary makes every assertion pass, which isolates it as
the trigger.

The five layout-identity assertions fail on main; the soft-navigation
control passes, so the teardown is the router discarding a layout it
should have retained rather than a full document load.

* fix(app-router): retain shared layouts across loading shells

* fix(app-router): preserve omitted slot segment identity

* test(app-router): follow shared param canonicalizer
2026-08-17 11:28:26 +01:00

4566 lines
152 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_BFCACHE_SEGMENT_IDENTITIES_KEY,
APP_LAYOUT_IDS_KEY,
APP_PREFETCH_LOADING_SHELL_MARKER_KEY,
APP_ROOT_LAYOUT_KEY,
APP_SOURCE_PAGE_SEGMENTS_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,
resolveAppPageLoadingModuleAtOrAbove,
} 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_EMPTY,
APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL,
} 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 {
APP_PAGE_INTERCEPTION_MARKER_TRAVERSALS,
buildPageElements as buildResolvedPageElements,
} from "../packages/vinext/src/server/app-page-element-builder.js";
import { createNextBfcacheIdMap } from "../packages/vinext/src/server/app-bfcache-identity.js";
import type { AppPageSemanticSegment } from "../packages/vinext/src/server/app-page-segment-state.js";
import { createAppPageRenderDependency } from "../packages/vinext/src/server/app-render-dependency.js";
import {
canCommitOptimisticRouteTemplate,
createOptimisticRouteTemplate,
} from "../packages/vinext/src/server/app-optimistic-routing.js";
import { createNestedBfcacheSlotSegmentId } from "../packages/vinext/src/server/bfcache-identity.js";
import type {
GraphVersion,
RouteManifest,
} from "../packages/vinext/src/routing/app-route-graph.js";
/**
* Build the resolved semantic branch the route matcher hands to slot overrides.
* Production parses filesystem syntax once, in
* `resolveInterceptedSlotIdentitySegments`; fixtures still spell segments the
* filesystem way, so this mirrors that resolution for readability.
*/
function toSemanticSegments(segments: readonly string[]): AppPageSemanticSegment[] {
let beforeMarker = true;
return segments.map((segment) => {
const marker =
APP_PAGE_INTERCEPTION_MARKER_TRAVERSALS.find(({ prefix }) => segment.startsWith(prefix))
?.prefix ?? null;
const paramSource = beforeMarker && marker === null ? "route" : "slot";
if (marker !== null) beforeMarker = false;
return {
marker,
paramSource,
segment: marker === null ? segment : segment.slice(marker.length),
};
});
}
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;
}
function countSuspenseWithFallback(node: unknown, fallbackTypeName: string): number {
if (Array.isArray(node)) {
return node.reduce(
(count, child) => count + countSuspenseWithFallback(child, fallbackTypeName),
0,
);
}
if (!isValidElement<InspectableElementProps>(node)) {
return 0;
}
const fallback = node.props.fallback;
const isMatch =
getElementTypeName(node.type) === "Symbol(react.suspense)" &&
isValidElement(fallback) &&
getElementTypeName(fallback.type) === fallbackTypeName;
return Object.values(node.props).reduce<number>(
(count, value) => count + countSuspenseWithFallback(value, fallbackTypeName),
isMatch ? 1 : 0,
);
}
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 renderRouteDocument(elements: AppElements, routeId: string): Promise<string> {
const { ElementsContext, Slot } = await import("../packages/vinext/src/shims/slot.js");
// Match production Flight serialization, which starts the flat page and
// route entries concurrently before the decoded route tree is rendered.
const pageEntry = Object.entries(elements).find(([key]) => key.startsWith("page:"))?.[1];
return renderHtml(
createElement(
"html",
null,
createElement("head"),
createElement(
"body",
null,
createElement(
ElementsContext.Provider,
{ value: elements },
createElement(
Fragment,
null,
pageEntry as ReactNode,
createElement(Slot, { id: routeId }),
),
),
),
),
);
}
function readDocumentSection(html: string, tagName: "head" | "body"): string {
const start = html.indexOf(`<${tagName}>`);
const end = html.indexOf(`</${tagName}>`);
if (start === -1 || end === -1) {
throw new Error(`Rendered document is missing <${tagName}>`);
}
return html.slice(start, end + tagName.length + 3);
}
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 NestedSlotLayout(props: Record<string, unknown>) {
return createElement("div", { "data-slot-layout": "nested" }, 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));
}
let lastGroupTemplateProps: Record<string, unknown> | null = null;
function GroupTemplate(props: Record<string, unknown>) {
lastGroupTemplateProps = props;
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,
serveStreamingMetadata?: boolean,
): 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",
alternates: {
canonical: "https://example.com/generated",
languages: { "en-US": "https://example.com/en/generated" },
},
robots: { index: false, follow: false },
};
},
},
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,
serveStreamingMetadata,
},
metadataRoutes: [],
htmlLimitedBots,
});
return renderRouteDocument(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("selects the nearest positioned loading module with a default export", () => {
const legacyLoading = { default: () => null };
const rootLoading = { default: () => null };
const leafWithoutDefault = { generateMetadata: () => null };
expect(
resolveAppPageLoadingModuleAtOrAbove(
{
loading: legacyLoading,
loadings: [rootLoading, leafWithoutDefault],
loadingTreePositions: [0, 2],
},
2,
),
).toBe(rootLoading);
expect(
resolveAppPageLoadingModuleAtOrAbove(
{
loading: legacyLoading,
loadings: [rootLoading],
loadingTreePositions: [],
},
2,
),
).toBe(legacyLoading);
});
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("streams generated metadata into the body 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");
const head = readDocumentSection(html, "head");
const body = readDocumentSection(html, "body");
expect(head).not.toContain("<title>generated page</title>");
expect(head).not.toContain('rel="canonical"');
expect(head).not.toContain('hreflang="en-US"');
expect(head).not.toContain('name="robots"');
expect(body).toContain("<title>generated page</title>");
expect(body).toContain('rel="canonical" href="https://example.com/generated"');
expect(body).toContain('href="https://example.com/en/generated" hreflang="en-US"');
expect(body).toContain('name="robots" content="noindex, nofollow"');
});
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");
const head = readDocumentSection(html, "head");
const body = readDocumentSection(html, "body");
expect(head).toContain("<title>generated page</title>");
expect(head).toContain('rel="canonical" href="https://example.com/generated"');
expect(body).not.toContain("<title>generated page</title>");
});
it("streams generated metadata for bots outside the configured html-limited rule", async () => {
// The custom rule replaces the default bot list, so Twitterbot is streaming-capable here.
const html = await buildGeneratedMetadataRouteHtml("Twitterbot", "Minibot");
const head = readDocumentSection(html, "head");
const body = readDocumentSection(html, "body");
expect(head).not.toContain("<title>generated page</title>");
expect(body).toContain("<title>generated page</title>");
});
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");
const head = readDocumentSection(html, "head");
const body = readDocumentSection(html, "body");
expect(head).toContain("<title>generated page</title>");
expect(head).toContain('rel="canonical" href="https://example.com/generated"');
expect(body).not.toContain("<title>generated page</title>");
});
it("renders generated metadata in the head when streaming is disabled for prerendering", async () => {
// Ported from Next.js: test/e2e/app-dir/metadata-streaming-static-generation/metadata-streaming-static-generation.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/metadata-streaming-static-generation/metadata-streaming-static-generation.test.ts
const html = await buildGeneratedMetadataRouteHtml("HeadlessChrome", undefined, false);
const head = readDocumentSection(html, "head");
const body = readDocumentSection(html, "body");
expect(head).toContain("<title>generated page</title>");
expect(body).not.toContain("<title>generated page</title>");
});
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", "");
const head = readDocumentSection(html, "head");
const body = readDocumentSection(html, "body");
expect(head).not.toContain("<title>generated page</title>");
expect(body).toContain("<title>generated page</title>");
});
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", "__PAGE__"]);
});
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_SEGMENTS_KEY]).toEqual(sourcePage.slice(1).split("/"));
expect(AppElementsWire.readMetadata(elements).sourcePage).toBe(sourcePage);
}
});
it("builds a flat elements map with route, layout, template, page, and slot entries", async () => {
lastGroupTemplateProps = null;
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.__srcPage).toEqual(["(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(lastGroupTemplateProps).not.toHaveProperty("params");
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("keeps default children segments at the parent for synthetic named-slot routes", async () => {
const elements = buildAppPageElements({
element: createElement(PageProbe),
makeThenableParams(params) {
return Promise.resolve(params);
},
matchedParams: {},
resolvedMetadata: null,
resolvedViewport: {},
route: {
childrenRouteSegments: ["dashboard"],
error: null,
errors: [null],
layoutTreePositions: [1],
layouts: [{ default: RootLayout }],
loading: null,
notFound: null,
notFounds: [null],
routeSegments: ["dashboard", "members"],
slots: {
sidebar: {
default: null,
error: null,
layout: null,
layoutIndex: 0,
loading: null,
name: "sidebar",
page: { default: SlotPage },
routeSegments: ["members"],
},
},
templateTreePositions: [],
templates: [],
},
routePath: "/dashboard/members",
rootNotFoundModule: null,
});
const html = await renderRouteEntry(elements, "route:/dashboard/members");
expect(html).toContain('data-segments=""');
expect(html).toContain('data-sidebar-segments="members"');
});
it("omits mounted default-only named-slot state from soft-navigation providers", () => {
const elements = buildAppPageElements({
element: createElement(PageProbe),
isRscRequest: true,
makeThenableParams(params) {
return Promise.resolve(params);
},
matchedParams: {},
mountedSlotIds: new Set(["slot:sidebar:/"]),
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errors: [null],
layoutTreePositions: [0],
layouts: [{ default: RootLayout }],
loading: null,
notFound: null,
notFounds: [null],
routeSegments: ["dashboard", "settings"],
slots: {
sidebar: {
default: { default: SlotPage },
error: null,
layout: null,
layoutIndex: 0,
loading: null,
name: "sidebar",
page: null,
routeSegments: null,
},
},
templateTreePositions: [],
templates: [],
},
routePath: "/dashboard/settings",
rootNotFoundModule: null,
});
const provider = findElementByTypeName(
elements["route:/dashboard/settings"],
"LayoutSegmentProvider",
);
expect(provider?.props.segmentMap).toEqual({ children: ["dashboard", "settings"] });
expect(provider?.props.providerId).toBe("layout:/");
});
it("omits mounted unmatched named-slot state without default.tsx from providers", () => {
const elements = buildAppPageElements({
element: createElement(PageProbe),
isRscRequest: true,
makeThenableParams(params) {
return Promise.resolve(params);
},
matchedParams: {},
mountedSlotIds: new Set(["slot:sidebar:/"]),
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errors: [null],
layoutTreePositions: [0],
layouts: [{ default: RootLayout }],
loading: null,
notFound: null,
notFounds: [null],
routeSegments: ["dashboard", "settings"],
slots: {
sidebar: {
default: null,
error: null,
layout: null,
layoutIndex: 0,
loading: null,
name: "sidebar",
page: null,
routeSegments: null,
},
},
templateTreePositions: [],
templates: [],
},
routePath: "/dashboard/settings",
rootNotFoundModule: null,
});
const provider = findElementByTypeName(
elements["route:/dashboard/settings"],
"LayoutSegmentProvider",
);
expect(provider?.props.segmentMap).toEqual({ children: ["dashboard", "settings"] });
expect(provider?.props.providerId).toBe("layout:/");
});
it("renders nested active slot layouts inside the slot root layout", 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: {
configLayouts: [{ default: NestedSlotLayout }],
default: null,
error: null,
layout: { default: SlotLayout },
layoutIndex: 0,
loading: null,
name: "sidebar",
page: { default: SlotPage },
routeSegments: ["members"],
},
},
templateTreePositions: [],
templates: [],
},
routePath: "/dashboard",
rootNotFoundModule: null,
});
const html = await renderRouteEntry(elements, "route:/dashboard");
expect(html.indexOf('data-slot-layout="sidebar"')).toBeLessThan(
html.indexOf('data-slot-layout="nested"'),
);
expect(html.indexOf('data-slot-layout="nested"')).toBeLessThan(html.indexOf("data-slot-page"));
});
it("keeps route loading boundaries in the rendered tree", () => {
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: {},
templateTreePositions: [],
templates: [],
},
routePath: "/dashboard",
rootNotFoundModule: null,
});
expect(containsElementType(elements["route:/dashboard"], RouteLoadingProbe)).toBe(true);
});
it("keeps slot loading boundaries in the rendered tree", () => {
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: ["dashboard"],
slots: {
sidebar: {
default: null,
error: null,
layout: null,
layoutIndex: -1,
loading: { default: SlotLoadingProbe },
name: "sidebar",
page: { default: SlotPage },
routeSegments: ["members"],
},
},
templateTreePositions: [],
templates: [],
},
routePath: "/dashboard",
rootNotFoundModule: null,
});
expect(containsElementType(elements["slot:sidebar:/"], SlotLoadingProbe)).toBe(true);
});
it("applies an owning segment loading boundary to named parallel slots", () => {
const elements = buildAppPageElements({
element: createElement(PageProbe),
makeThenableParams(params) {
return Promise.resolve(params);
},
matchedParams: {},
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errors: [],
layoutTreePositions: [],
layouts: [],
loading: null,
loadings: [{ default: RouteLoadingProbe }],
loadingTreePositions: [0],
notFound: null,
notFounds: [null],
routeSegments: ["dashboard"],
slots: {
sidebar: {
default: null,
error: null,
layout: null,
layoutIndex: -1,
loading: null,
name: "sidebar",
ownerTreePosition: 0,
page: { default: SlotPage },
routeSegments: [],
},
},
templateTreePositions: [],
templates: [],
},
routePath: "/dashboard",
rootNotFoundModule: null,
});
expect(
containsElementType(
elements[AppElementsWire.encodeSlotId("sidebar", "/")],
RouteLoadingProbe,
),
).toBe(true);
});
it("applies the nearest loading boundary above a named-slot owner", () => {
const elements = buildAppPageElements({
element: createElement(PageProbe),
makeThenableParams(params) {
return Promise.resolve(params);
},
matchedParams: {},
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errors: [],
layoutTreePositions: [],
layouts: [],
loading: null,
loadings: [{ default: RouteLoadingProbe }],
loadingTreePositions: [0],
notFound: null,
notFounds: [],
routeSegments: ["dashboard", "members"],
slots: {
sidebar: {
default: null,
error: null,
layout: null,
layoutIndex: -1,
loading: null,
name: "sidebar",
ownerTreePosition: 1,
page: { default: SlotPage },
routeSegments: [],
},
},
templateTreePositions: [],
templates: [],
},
routePath: "/dashboard/members",
rootNotFoundModule: null,
});
expect(
containsElementType(
elements[AppElementsWire.encodeSlotId("sidebar", "/")],
RouteLoadingProbe,
),
).toBe(true);
});
it("positions nested named-slot loading boundaries inside co-located layouts", () => {
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: [null],
routeSegments: ["dashboard"],
slots: {
sidebar: {
configLayouts: [{ default: NestedSlotLayout }],
configLayoutTreePositions: [1],
default: null,
error: null,
layout: null,
layoutIndex: -1,
loading: null,
loadings: [{ default: SlotLoadingProbe }],
loadingTreePositions: [1],
name: "sidebar",
page: { default: SlotPage },
routeSegments: ["members"],
},
},
templateTreePositions: [],
templates: [],
},
routePath: "/dashboard",
rootNotFoundModule: null,
});
const slotEntry = elements[AppElementsWire.encodeSlotId("sidebar", "/")];
const nestedLayout = findElementByTypeName(slotEntry, "NestedSlotLayout");
expect(nestedLayout).not.toBeNull();
expect(
findSuspenseWithFallback(nestedLayout?.props.children, "SlotLoadingProbe"),
).not.toBeNull();
expect(
containsElementType(
findSuspenseWithFallback(nestedLayout?.props.children, "SlotLoadingProbe")?.props.children,
NestedSlotLayout,
),
).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("serializes the nearest ancestor loading UI 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: null,
loadings: [{ default: RouteLoadingProbe }],
loadingTreePositions: [0],
notFound: null,
notFounds: [null],
routeSegments: ["dashboard", "slow"],
templateTreePositions: [],
templates: [],
},
routePath: "/dashboard/slow",
rootNotFoundModule: null,
renderMode: APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL,
});
expect(elements[APP_PREFETCH_LOADING_SHELL_MARKER_KEY]).toBe("LoadingBoundary");
const html = await renderRouteEntry(elements, "route:/dashboard/slow");
expect(html).toContain("Route loading");
expect(html).not.toContain("Page");
});
it("stops loading-shell prefetches at the first ancestor boundary", async () => {
function ParentLoading(): ReactNode {
return createElement("p", null, "Parent loading");
}
function LeafLoading(): ReactNode {
return createElement("p", null, "Leaf loading");
}
function DescendantLayout(props: Record<string, unknown>): ReactNode {
return createElement("section", { "data-descendant": "true" }, readChildren(props.children));
}
const elements = buildAppPageElements({
element: createElement(PageProbe),
makeThenableParams(params) {
return Promise.resolve(params);
},
matchedParams: {},
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errors: [null, null],
layoutTreePositions: [0, 2],
layouts: [{ default: RootLayout }, { default: DescendantLayout }],
loading: { default: LeafLoading },
loadings: [{ default: ParentLoading }, { default: LeafLoading }],
loadingTreePositions: [1, 2],
notFound: null,
notFounds: [null, null],
routeSegments: ["parent", "slow"],
slots: {},
templateTreePositions: [],
templates: [],
},
routePath: "/parent/slow",
rootNotFoundModule: null,
renderMode: APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL,
});
expect(elements["layout:/parent/slow"]).toBeUndefined();
const html = await renderRouteEntry(elements, "route:/parent/slow");
expect(html).toContain("Parent loading");
expect(html).not.toContain("Leaf loading");
expect(html).not.toContain('data-descendant="true"');
});
it("skips an already-shared root loading boundary for loading-shell prefetches", async () => {
function RootLoading(): ReactNode {
return createElement("p", null, "Root loading");
}
function NestedLoading(): ReactNode {
return createElement("p", null, "Nested loading");
}
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: NestedLoading },
loadings: [{ default: RootLoading }, { default: NestedLoading }],
loadingTreePositions: [0, 2],
notFound: null,
notFounds: [null],
routeSegments: ["prefetch-auto", "slug"],
templateTreePositions: [],
templates: [],
},
routePath: "/prefetch-auto/slug",
rootNotFoundModule: null,
renderMode: APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL,
});
const html = await renderRouteEntry(elements, "route:/prefetch-auto/slug");
expect(html).toContain("Nested loading");
expect(html).not.toContain("Root loading");
});
it("builds slot-only loading shells and omits unprotected parallel branches", async () => {
function SlotLoading(): ReactNode {
return createElement("p", null, "Slot loading shell");
}
function UnprotectedPage(): ReactNode {
return createElement("p", null, "Unprotected slot page");
}
const buildElements = (
renderMode?: typeof APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL,
): AppElements =>
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: {
configLayouts: [{ default: NestedSlotLayout }],
configLayoutTreePositions: [1],
default: null,
error: null,
layout: null,
layoutIndex: 0,
loading: null,
loadings: [{ default: SlotLoading }],
loadingTreePositions: [0],
name: "sidebar",
ownerTreePosition: 0,
page: { default: SlotPage },
routeSegments: ["slow", "child"],
},
panel: {
default: null,
error: null,
layout: null,
layoutIndex: 0,
loading: null,
name: "panel",
ownerTreePosition: 0,
page: { default: UnprotectedPage },
routeSegments: [],
},
},
templateTreePositions: [],
templates: [],
},
routePath: "/dashboard",
rootNotFoundModule: null,
...(renderMode ? { renderMode } : {}),
});
const elements = buildElements(APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL);
expect(elements[APP_PREFETCH_LOADING_SHELL_MARKER_KEY]).toBe("LoadingBoundary");
expect(elements[AppElementsWire.encodeSlotId("panel", "/")]).toBeUndefined();
const html = await renderRouteEntry(elements, "route:/dashboard");
expect(html).toContain("Slot loading shell");
expect(html).not.toContain("Unprotected slot page");
expect(html).not.toContain("Page");
const rootLayoutId = AppElementsWire.encodeLayoutId("/");
const sidebarSlotId = AppElementsWire.encodeSlotId("sidebar", "/");
const routeId = "route:/dashboard";
const routeManifest: RouteManifest = {
graphVersion: "graph:test" as GraphVersion,
segmentGraph: {
boundaries: new Map(),
defaults: new Map(),
interceptions: new Map(),
interceptionsBySlotId: new Map(),
layouts: new Map([
[
rootLayoutId,
{
id: rootLayoutId,
paramNames: [],
patternParts: [],
rootBoundaryId: null,
treePath: "/",
},
],
]),
pages: new Map(),
rootBoundaries: new Map(),
routeHandlers: new Map(),
routes: new Map([
[
routeId,
{
id: routeId,
isDynamic: false,
layoutIds: [rootLayoutId],
pageId: "page:/dashboard",
paramNames: [],
pattern: "/dashboard",
patternParts: ["dashboard"],
rootBoundaryId: null,
rootParamNames: [],
routeHandlerId: null,
slotIds: [sidebarSlotId],
templateIds: [],
},
],
]),
slotBindings: new Map([
[
`${routeId}::${sidebarSlotId}`,
{
defaultId: null,
id: `${routeId}::${sidebarSlotId}`,
ownerLayoutId: rootLayoutId,
routeId,
routeSegments: ["slow", "child"],
slotId: sidebarSlotId,
state: "active",
},
],
]),
slots: new Map(),
templates: new Map(),
},
};
const template = createOptimisticRouteTemplate({
allowLoadingShell: true,
basePath: "",
elements,
href: "/dashboard",
interceptionContext: null,
mountedSlotsHeader: "sidebar",
routeManifest,
});
if (template === null) throw new Error("Expected optimistic route template");
const omittedSegmentId = createNestedBfcacheSlotSegmentId(sidebarSlotId, 2);
expect(template.omittedBfcacheSegmentIds).toContain(omittedSegmentId);
const currentElements = buildElements();
expect(
canCommitOptimisticRouteTemplate({
currentElements,
currentLayoutIds: AppElementsWire.readMetadata(currentElements).layoutIds,
currentParams: {},
routeManifest,
targetRouteParams: {},
targetUrlParts: ["dashboard"],
template,
}),
).toBe(false);
});
it("emits the route loading fallback for slots owned at the shell cutoff", 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 },
loadings: [{ default: RouteLoadingProbe }],
loadingTreePositions: [0],
notFound: null,
notFounds: [null],
routeSegments: ["dashboard"],
slots: {
sidebar: {
default: null,
error: null,
layout: null,
layoutIndex: 0,
loading: { default: SlotLoadingProbe },
name: "sidebar",
ownerTreePosition: 0,
page: { default: SlotPage },
routeSegments: [],
},
},
templateTreePositions: [],
templates: [],
},
routePath: "/dashboard",
rootNotFoundModule: null,
renderMode: APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL,
});
const html = await renderRouteEntry(elements, "route:/dashboard");
expect(html.match(/Route loading/g)).toHaveLength(2);
expect(html).not.toContain("Slot page");
});
it("uses the slot owner position when no layout exists at the loading cutoff", 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,
loadings: [{ default: RouteLoadingProbe }],
loadingTreePositions: [1],
notFound: null,
notFounds: [null],
routeSegments: ["foo", "photo"],
slots: {
sidebar: {
default: null,
error: null,
layout: null,
layoutIndex: 0,
loading: { default: SlotLoadingProbe },
name: "sidebar",
ownerTreePosition: 1,
page: { default: SlotPage },
routeSegments: ["photo"],
},
},
templateTreePositions: [],
templates: [],
},
routePath: "/foo/photo",
rootNotFoundModule: null,
renderMode: APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL,
});
const html = await renderRouteEntry(elements, "route:/foo/photo");
expect(html.match(/Route loading/g)).toHaveLength(2);
expect(html).not.toContain("Slot 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("omits page, layout, and loading content for empty Next prefetch payloads", 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"],
slots: {
sidebar: {
default: null,
error: null,
layout: null,
layoutIndex: 0,
loading: null,
name: "sidebar",
page: { default: SlotPage },
routeSegments: ["members"],
},
},
templateTreePositions: [],
templates: [],
},
routePath: "/dashboard",
rootNotFoundModule: null,
renderMode: APP_RSC_RENDER_MODE_PREFETCH_EMPTY,
streamingMetadata: Promise.resolve(null),
streamingMetadataOutlet: Promise.resolve(null),
});
const html = await renderRouteEntry(elements, "route:/dashboard");
expect(html).not.toContain("Page");
expect(html).not.toContain("Layout");
expect(html).not.toContain("Route loading");
expect(html).not.toContain("slot-page");
expect(Object.keys(elements).some((key) => key.startsWith("slot:"))).toBe(false);
expect(Object.keys(elements).some((key) => key.startsWith("__vinext_streaming_metadata"))).toBe(
false,
);
expect(elements[APP_LAYOUT_IDS_KEY]).toEqual([]);
expect(elements[APP_ROOT_LAYOUT_KEY]).toBeNull();
});
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("uses override route segments for intercepted named slots", 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: [],
slots: {
modal: {
default: { default: SlotPage },
error: null,
layout: null,
layoutIndex: 0,
loading: null,
name: "modal",
page: null,
routeSegments: null,
},
},
templateTreePositions: [],
templates: [],
},
routePath: "/interception-dyn-seg",
rootNotFoundModule: null,
slotOverrides: {
modal: {
pageModule: { default: SlotPage },
params: { username: "foo", id: "1" },
routeSegments: ["[username]", "[id]"],
},
},
});
const provider = findElementByTypeName(
elements["route:/interception-dyn-seg"],
"LayoutSegmentProvider",
);
expect(provider?.props.segmentMap).toEqual({ children: [], modal: ["foo", "1"] });
expect(provider?.props.providerId).toBe("layout:/");
});
it("uses intercepted override segments for named slot reset boundaries", async () => {
function SlotError() {
return createElement("div", null, "slot error");
}
const buildInterceptedElements = (params: { username: string; id: string }) =>
buildAppPageElements({
element: createElement(PageProbe),
makeThenableParams(value) {
return Promise.resolve(value);
},
matchedParams: {},
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errors: [null],
layoutTreePositions: [],
layouts: [],
loading: null,
notFound: null,
notFounds: [null],
routeSegments: [],
slots: {
modal: {
default: null,
error: { default: SlotError },
layout: null,
layoutIndex: -1,
loading: null,
name: "modal",
page: null,
routeSegments: null,
},
},
templateTreePositions: [],
templates: [],
},
routePath: "/interception-dyn-seg",
rootNotFoundModule: null,
slotOverrides: {
modal: {
pageModule: { default: SlotPage },
params,
routeSegments: ["(group)", "[username]", "(nested)", "[id]"],
},
},
});
const modalSlotId = AppElementsWire.encodeSlotId("modal", "/");
const fooElements = buildInterceptedElements({ username: "foo", id: "1" });
const fooOtherIdElements = buildInterceptedElements({ username: "foo", id: "2" });
const barElements = buildInterceptedElements({ username: "bar", id: "2" });
const fooBoundary = findElementByTypeName(fooElements[modalSlotId], "ErrorBoundary");
const barBoundary = findElementByTypeName(barElements[modalSlotId], "ErrorBoundary");
expect(fooBoundary?.props.resetKey).toBe(JSON.stringify(["username|foo|d", "id|1|d"]));
expect(barBoundary?.props.resetKey).toBe(JSON.stringify(["username|bar|d", "id|2|d"]));
expect(barBoundary?.props.resetKey).not.toBe(fooBoundary?.props.resetKey);
const fooIdentity =
AppElementsWire.readMetadata(fooElements).bfcacheSegmentIdentities[modalSlotId];
const barIdentity =
AppElementsWire.readMetadata(barElements).bfcacheSegmentIdentities[modalSlotId];
const fooOtherIdIdentity =
AppElementsWire.readMetadata(fooOtherIdElements).bfcacheSegmentIdentities[modalSlotId];
const repeatedFooIdentity = AppElementsWire.readMetadata(
buildInterceptedElements({ username: "foo", id: "1" }),
).bfcacheSegmentIdentities[modalSlotId];
expect(fooIdentity).toBeTypeOf("string");
expect(barIdentity).toBeTypeOf("string");
expect(fooIdentity).not.toBe(fooBoundary?.props.resetKey);
expect(barIdentity).not.toBe(barBoundary?.props.resetKey);
expect(barIdentity).toBe(fooIdentity);
expect(fooOtherIdIdentity).toBe(fooIdentity);
expect(repeatedFooIdentity).toBe(fooIdentity);
const fooIdentities = AppElementsWire.readMetadata(fooElements).bfcacheSegmentIdentities;
const fooOtherIdIdentities =
AppElementsWire.readMetadata(fooOtherIdElements).bfcacheSegmentIdentities;
const nestedSegmentIds = Object.keys(fooIdentities)
.filter((id) => id.startsWith("slot:\0vinext_bfcache_segment_"))
.sort();
expect(nestedSegmentIds).toHaveLength(4);
const [outerGroupSegmentId, usernameSegmentId, nestedGroupSegmentId, idSegmentId] =
nestedSegmentIds;
expect(fooOtherIdIdentities[outerGroupSegmentId]).toBe(fooIdentities[outerGroupSegmentId]);
expect(fooOtherIdIdentities[usernameSegmentId]).toBe(fooIdentities[usernameSegmentId]);
expect(fooOtherIdIdentities[nestedGroupSegmentId]).toBe(fooIdentities[nestedGroupSegmentId]);
expect(fooOtherIdIdentities[idSegmentId]).not.toBe(fooIdentities[idSegmentId]);
const fooBfcacheIds = createNextBfcacheIdMap({
current: {},
currentElements: fooElements,
elements: fooElements,
});
const fooOtherIdBfcacheIds = createNextBfcacheIdMap({
current: fooBfcacheIds,
currentElements: fooElements,
elements: fooOtherIdElements,
});
expect(fooOtherIdBfcacheIds[modalSlotId]).toBe(fooBfcacheIds[modalSlotId]);
expect(fooOtherIdBfcacheIds[outerGroupSegmentId]).toBe(fooBfcacheIds[outerGroupSegmentId]);
expect(fooOtherIdBfcacheIds[usernameSegmentId]).toBe(fooBfcacheIds[usernameSegmentId]);
expect(fooOtherIdBfcacheIds[nestedGroupSegmentId]).toBe(fooBfcacheIds[nestedGroupSegmentId]);
expect(fooOtherIdBfcacheIds[idSegmentId]).not.toBe(fooBfcacheIds[idSegmentId]);
const missingProofElements = { ...fooOtherIdElements };
delete missingProofElements[APP_BFCACHE_SEGMENT_IDENTITIES_KEY];
const missingProofIds = createNextBfcacheIdMap({
current: fooBfcacheIds,
currentElements: fooElements,
elements: missingProofElements,
});
expect(missingProofIds[idSegmentId]).toMatch(/^_b_\d+_$/);
expect(missingProofIds[idSegmentId]).not.toBe(fooBfcacheIds[idSegmentId]);
const previousCacheComponents = process.env.__NEXT_CACHE_COMPONENTS;
process.env.__NEXT_CACHE_COMPONENTS = "true";
try {
const cachedElements = buildInterceptedElements({ username: "foo", id: "1" });
expect(
findElementByTypeName(cachedElements[modalSlotId], "BfcacheSegmentBoundary")?.props
.stateKey,
).toBe("(group)");
expect(
findElementByTypeName(cachedElements[modalSlotId], "BfcacheSegmentBoundary")?.props.id,
).toBe(outerGroupSegmentId);
} finally {
if (previousCacheComponents === undefined) {
delete process.env.__NEXT_CACHE_COMPONENTS;
} else {
process.env.__NEXT_CACHE_COMPONENTS = previousCacheComponents;
}
}
});
it("retains matching intercepted layouts with marker-bearing branch segments", () => {
function PhotosLayout(props: { children?: ReactNode }) {
return createElement("section", { "data-photos-layout": true }, props.children);
}
function AlbumLayout(props: { children?: ReactNode }) {
return createElement("section", { "data-album-layout": true }, props.children);
}
const buildElements = (album: string, id: string) =>
buildAppPageElements({
element: createElement(PageProbe),
makeThenableParams(value) {
return Promise.resolve(value);
},
matchedParams: {},
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errors: [null],
layoutTreePositions: [],
layouts: [],
loading: null,
notFound: null,
notFounds: [null],
routeSegments: [],
slots: {
modal: {
default: null,
error: null,
layout: null,
layoutIndex: -1,
loading: null,
name: "modal",
page: null,
routeSegments: null,
},
},
templateTreePositions: [],
templates: [],
},
routePath: `/photos/${album}/${id}`,
rootNotFoundModule: null,
slotOverrides: {
modal: {
branchSegments: ["photos", "[album]", "[id]"],
identitySegments: toSemanticSegments(["(.)photos", "[album]", "[id]"]),
layoutModules: [
{ default: PhotosLayout } as AppPageModule,
{ default: AlbumLayout } as AppPageModule,
],
layoutSegments: [["(.)photos"], ["(.)photos", "[album]"]],
pageModule: { default: SlotPage },
params: { album, id },
routeSegments: ["photos", "[album]", "[id]"],
},
},
});
const modalSlotId = AppElementsWire.encodeSlotId("modal", "/");
const firstElements = buildElements("a", "1");
const changedLeafElements = buildElements("a", "2");
const firstIdentities = AppElementsWire.readMetadata(firstElements).bfcacheSegmentIdentities;
const changedLeafIdentities =
AppElementsWire.readMetadata(changedLeafElements).bfcacheSegmentIdentities;
const nestedIds = Object.keys(firstIdentities)
.filter((id) => id.startsWith("slot:\0vinext_bfcache_segment_"))
.sort();
expect(nestedIds).toHaveLength(3);
const [photosSegmentId, albumSegmentId, idSegmentId] = nestedIds;
const slotElement = firstElements[modalSlotId];
expect(isValidElement(slotElement) && getElementTypeName(slotElement.type)).toBe(
"BfcacheSegmentBoundary",
);
expect(isValidElement<Record<string, unknown>>(slotElement) ? slotElement.props.id : null).toBe(
photosSegmentId,
);
expect(findElementByTypeName(slotElement, "PhotosLayout")).not.toBeNull();
expect(findElementByTypeName(slotElement, "AlbumLayout")).not.toBeNull();
expect(
findElement(
slotElement,
(element) =>
getElementTypeName(element.type) === "BfcacheSegmentBoundary" &&
element.props.id === albumSegmentId,
)?.props.stateKey,
).toBe("album|a|d");
expect(
findElement(
slotElement,
(element) =>
getElementTypeName(element.type) === "BfcacheSegmentBoundary" &&
element.props.id === idSegmentId,
)?.props.stateKey,
).toBe("id|1|d");
expect(changedLeafIdentities[albumSegmentId]).toBe(firstIdentities[albumSegmentId]);
expect(changedLeafIdentities[idSegmentId]).not.toBe(firstIdentities[idSegmentId]);
const firstBfcacheIds = createNextBfcacheIdMap({
current: {},
currentElements: firstElements,
elements: firstElements,
});
const changedLeafBfcacheIds = createNextBfcacheIdMap({
current: firstBfcacheIds,
currentElements: firstElements,
elements: changedLeafElements,
});
expect(changedLeafBfcacheIds[albumSegmentId]).toBe(firstBfcacheIds[albumSegmentId]);
expect(changedLeafBfcacheIds[idSegmentId]).not.toBe(firstBfcacheIds[idSegmentId]);
});
it("preserves source-only interception ancestors and distinguishes conventions", () => {
function SourceLayout(props: { children?: ReactNode }) {
return createElement("section", { "data-source-layout": true }, props.children);
}
const modalSlotId = AppElementsWire.encodeSlotId("modal", "/");
const buildElements = (
sourceId: string,
targetId: string,
marker = "(..)photo",
targetKind = "photo",
) =>
buildAppPageElements({
element: createElement(PageProbe),
interception: {
sourceMatchedUrl: `/source/${sourceId}`,
sourceRouteId: `route:/source/${sourceId}`,
slotId: modalSlotId,
targetMatchedUrl: `/${targetKind}/${targetId}`,
targetRouteId: `route:/${targetKind}/${targetId}`,
},
makeThenableParams(value) {
return Promise.resolve(value);
},
matchedParams: { id: sourceId },
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errors: [null],
ids: {
layouts: [],
page: "graph-page:/source/[id]",
rootBoundary: "graph-root:/",
route: "graph-route:/source/[id]",
routeHandler: null,
slots: { modal: "graph-slot:modal" },
templates: [],
},
layoutTreePositions: [],
layouts: [],
loading: null,
notFound: null,
notFounds: [null],
routeSegments: ["[id]"],
slots: {
modal: {
default: null,
error: null,
layout: null,
layoutIndex: -1,
loading: null,
name: "modal",
page: null,
routeSegments: null,
},
},
templateTreePositions: [],
templates: [],
},
routePath: `/${targetKind}/${targetId}`,
rootNotFoundModule: null,
semanticInterceptionTargetRouteId: `graph-route:/${targetKind}/[id]`,
slotOverrides: {
modal: {
branchSegments: ["[id]", "photo", "[id]"],
identitySegments: toSemanticSegments(["[id]", marker, "[id]"]),
layoutModules: [{ default: SourceLayout } as AppPageModule],
layoutSegments: [["[id]"]],
pageModule: { default: SlotPage },
params: { id: targetId },
routeSegments: ["photo", "[id]"],
},
},
});
const firstElements = buildElements("source-a", "target-1");
const changedLeafElements = buildElements("source-a", "target-2");
const otherSourceElements = buildElements("source-b", "target-1");
const otherConventionElements = buildElements("source-a", "target-1", "(.)photo");
const otherTargetElements = buildElements("source-a", "target-1", "(..)video", "video");
const firstIdentities = AppElementsWire.readMetadata(firstElements).bfcacheSegmentIdentities;
const changedLeafIdentities =
AppElementsWire.readMetadata(changedLeafElements).bfcacheSegmentIdentities;
const otherConventionIdentities =
AppElementsWire.readMetadata(otherConventionElements).bfcacheSegmentIdentities;
const otherSourceIdentities =
AppElementsWire.readMetadata(otherSourceElements).bfcacheSegmentIdentities;
const otherTargetIdentities =
AppElementsWire.readMetadata(otherTargetElements).bfcacheSegmentIdentities;
const nestedIds = Object.keys(firstIdentities)
.filter((id) => id.startsWith("slot:\0vinext_bfcache_segment_"))
.sort();
expect(nestedIds).toHaveLength(3);
const [sourceSegmentId, targetSegmentId, leafSegmentId] = nestedIds;
expect(isValidElement(firstElements[modalSlotId])).toBe(true);
expect(getElementTypeName((firstElements[modalSlotId] as ReactElement).type)).toBe(
"BfcacheSegmentBoundary",
);
expect(findElementByTypeName(firstElements[modalSlotId], "SourceLayout")).not.toBeNull();
expect(changedLeafIdentities[modalSlotId]).toBe(firstIdentities[modalSlotId]);
expect(changedLeafIdentities[sourceSegmentId]).toBe(firstIdentities[sourceSegmentId]);
expect(changedLeafIdentities[targetSegmentId]).toBe(firstIdentities[targetSegmentId]);
expect(changedLeafIdentities[leafSegmentId]).not.toBe(firstIdentities[leafSegmentId]);
expect(otherSourceIdentities[modalSlotId]).toBe(firstIdentities[modalSlotId]);
expect(otherSourceIdentities[sourceSegmentId]).not.toBe(firstIdentities[sourceSegmentId]);
expect(otherTargetIdentities[sourceSegmentId]).toBe(firstIdentities[sourceSegmentId]);
expect(otherTargetIdentities[targetSegmentId]).not.toBe(firstIdentities[targetSegmentId]);
expect(otherConventionIdentities[targetSegmentId]).not.toBe(firstIdentities[targetSegmentId]);
expect(otherConventionIdentities[leafSegmentId]).not.toBe(firstIdentities[leafSegmentId]);
});
it.each([
["short", ["(.)photo"]],
["long", ["(.)photo", "[id]", "extra"]],
])("omits authoritative proof for %s interception identity metadata", (_, identitySegments) => {
const modalSlotId = AppElementsWire.encodeSlotId("modal", "/");
const elements = buildAppPageElements({
element: createElement(PageProbe),
makeThenableParams(value) {
return Promise.resolve(value);
},
matchedParams: {},
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errors: [null],
layoutTreePositions: [],
layouts: [],
loading: null,
notFound: null,
notFounds: [null],
routeSegments: [],
slots: {
modal: {
default: null,
error: null,
layout: null,
layoutIndex: -1,
loading: null,
name: "modal",
page: null,
routeSegments: null,
},
},
templateTreePositions: [],
templates: [],
},
routePath: "/photo/1",
rootNotFoundModule: null,
slotOverrides: {
modal: {
branchSegments: ["photo", "[id]"],
identitySegments: toSemanticSegments(identitySegments),
pageModule: { default: SlotPage },
params: { id: "1" },
routeSegments: ["photo", "[id]"],
},
},
});
const identities = AppElementsWire.readMetadata(elements).bfcacheSegmentIdentities;
const fallbackBoundary = findElementByTypeName(elements[modalSlotId], "BfcacheSegmentBoundary");
expect(identities[modalSlotId]).toBeUndefined();
expect(
Object.keys(identities).some((id) => id.startsWith("slot:\0vinext_bfcache_segment_")),
).toBe(false);
expect(fallbackBoundary?.props.stateKey).toBe(JSON.stringify(["photo", "id|1|d"]));
});
it("emits opaque slot identities from effective route facts", async () => {
const modalSlotId = AppElementsWire.encodeSlotId("modal", "/");
const buildSlotElements = (options: {
defaultModule: AppPageModule | null;
pageModule: AppPageModule | null;
targetPath?: string;
}) =>
buildAppPageElements({
element: createElement(PageProbe),
makeThenableParams(value) {
return Promise.resolve(value);
},
matchedParams: { id: "42" },
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errors: [null],
layoutTreePositions: [],
layouts: [],
loading: null,
notFound: null,
notFounds: [null],
routeSegments: ["photos", "[id]"],
slots: {
modal: {
default: options.defaultModule,
error: null,
layout: null,
layoutIndex: -1,
loading: null,
name: "modal",
page: options.pageModule,
routeSegments: ["photos", "[id]"],
},
},
templateTreePositions: [],
templates: [],
},
...(options.targetPath
? {
interception: {
sourceMatchedUrl: "/feed",
sourceRouteId: "route:/feed",
slotId: modalSlotId,
targetMatchedUrl: options.targetPath,
targetRouteId: `route:${options.targetPath}`,
},
interceptionContext: "/feed",
}
: {}),
routePath: "/photos/42",
rootNotFoundModule: null,
});
const activeIdentities = AppElementsWire.readMetadata(
buildSlotElements({
defaultModule: null,
pageModule: { default: SlotPage },
targetPath: "/photos/42",
}),
).bfcacheSegmentIdentities;
const changedTargetIdentities = AppElementsWire.readMetadata(
buildSlotElements({
defaultModule: null,
pageModule: { default: SlotPage },
targetPath: "/photos/43",
}),
).bfcacheSegmentIdentities;
const defaultIdentities = AppElementsWire.readMetadata(
buildSlotElements({ defaultModule: { default: SlotPage }, pageModule: null }),
).bfcacheSegmentIdentities;
const unmatchedIdentities = AppElementsWire.readMetadata(
buildSlotElements({ defaultModule: null, pageModule: null }),
).bfcacheSegmentIdentities;
const firstSegmentId = Object.keys(activeIdentities).find((id) =>
id.startsWith("slot:\0vinext_bfcache_segment_"),
);
expect(firstSegmentId).toBeTypeOf("string");
if (firstSegmentId === undefined) return;
expect(activeIdentities[modalSlotId]).toBe(changedTargetIdentities[modalSlotId]);
expect(activeIdentities[modalSlotId]).toBe(defaultIdentities[modalSlotId]);
expect(defaultIdentities[modalSlotId]).toBe(unmatchedIdentities[modalSlotId]);
expect(changedTargetIdentities[firstSegmentId]).toBe(activeIdentities[firstSegmentId]);
expect(defaultIdentities[firstSegmentId]).not.toBe(activeIdentities[firstSegmentId]);
expect(unmatchedIdentities[firstSegmentId]).not.toBe(defaultIdentities[firstSegmentId]);
});
it("derives BFCache identities from graph semantic ids when available", () => {
const modalSlotId = AppElementsWire.encodeSlotId("modal", "/");
const buildElements = (graph: {
pageId: string | null;
rootBoundaryId: string;
routeId: string;
}) =>
buildAppPageElements({
element: createElement(PageProbe),
makeThenableParams(value) {
return Promise.resolve(value);
},
matchedParams: { id: "42" },
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errors: [null],
ids: {
layouts: ["layout:/"],
page: graph.pageId,
rootBoundary: graph.rootBoundaryId,
route: graph.routeId,
routeHandler: null,
slots: { modal: "graph-slot:modal" },
templates: [],
},
layoutTreePositions: [0],
layouts: [{ default: RootLayout }],
loading: null,
notFound: null,
notFounds: [null],
routeSegments: ["photos", "[id]"],
slots: {
modal: {
default: null,
error: null,
id: modalSlotId,
layout: null,
layoutIndex: 0,
loading: null,
name: "modal",
page: { default: SlotPage },
routeSegments: ["photos", "[id]"],
},
},
templateTreePositions: [],
templates: [],
},
routePath: "/photos/42",
rootNotFoundModule: null,
});
const baseline = AppElementsWire.readMetadata(
buildElements({
pageId: "page:/photos/[id]",
rootBoundaryId: "root-boundary:/",
routeId: "route:/photos/[id]",
}),
).bfcacheSegmentIdentities;
const changedGraphIds = AppElementsWire.readMetadata(
buildElements({
pageId: "page:/albums/[id]",
rootBoundaryId: "root-boundary:/",
routeId: "route:/albums/[id]",
}),
).bfcacheSegmentIdentities;
const changedRootBoundary = AppElementsWire.readMetadata(
buildElements({
pageId: "page:/photos/[id]",
rootBoundaryId: "root-boundary:/(shell)",
routeId: "route:/photos/[id]",
}),
).bfcacheSegmentIdentities;
const layoutOnly = AppElementsWire.readMetadata(
buildElements({
pageId: null,
rootBoundaryId: "root-boundary:/",
routeId: "route:/photos/[id]",
}),
).bfcacheSegmentIdentities;
expect(changedGraphIds["page:/photos/42"]).not.toBe(baseline["page:/photos/42"]);
// An unchanged active parallel slot is independent of an unrelated change
// to the aggregate children route.
expect(changedGraphIds[modalSlotId]).toBe(baseline[modalSlotId]);
expect(changedRootBoundary["page:/photos/42"]).not.toBe(baseline["page:/photos/42"]);
expect(layoutOnly["page:/photos/42"]).toBeDefined();
expect(JSON.parse(baseline[modalSlotId])[1]).toBe("graph-slot:modal");
});
it.each([false, true])(
"binds nested slot identities to their dynamic owner with cacheComponents=%s",
(cacheComponents) => {
const previousCacheComponents = process.env.__NEXT_CACHE_COMPONENTS;
if (cacheComponents) {
process.env.__NEXT_CACHE_COMPONENTS = "true";
} else {
delete process.env.__NEXT_CACHE_COMPONENTS;
}
try {
const modalSlotId = AppElementsWire.encodeSlotId("modal", "/");
const buildElements = (teamID: string, catchAll: string) =>
buildAppPageElements({
element: createElement(PageProbe),
makeThenableParams(value) {
return Promise.resolve(value);
},
matchedParams: { catchAll: [catchAll], teamID },
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errors: [null],
ids: {
layouts: ["graph-layout:/"],
page: "graph-page:/[teamID]/dashboard",
rootBoundary: "graph-root:/",
route: "graph-route:/[teamID]/dashboard",
routeHandler: null,
slots: { modal: "graph-slot:modal" },
templates: [],
},
layoutTreePositions: [0],
layouts: [{ default: RootLayout }],
loading: null,
notFound: null,
notFounds: [null],
routeSegments: ["[teamID]", "dashboard"],
slots: {
modal: {
default: null,
error: null,
id: modalSlotId,
layout: null,
layoutIndex: 0,
loading: null,
name: "modal",
ownerTreePosition: 1,
page: { default: SlotPage },
routeSegments: ["projects", "[...catchAll]"],
},
},
templateTreePositions: [],
templates: [],
},
routePath: `/${teamID}/dashboard`,
rootNotFoundModule: null,
});
const alphaElements = buildElements("alpha", "same");
const betaElements = buildElements("beta", "same");
const alphaOtherLeafElements = buildElements("alpha", "other");
const alphaIdentities =
AppElementsWire.readMetadata(alphaElements).bfcacheSegmentIdentities;
const betaIdentities = AppElementsWire.readMetadata(betaElements).bfcacheSegmentIdentities;
const alphaOtherLeafIdentities =
AppElementsWire.readMetadata(alphaOtherLeafElements).bfcacheSegmentIdentities;
const nestedSegmentId = Object.keys(alphaIdentities)
.filter((id) => id.startsWith("slot:\0vinext_bfcache_segment_"))
.sort()
.at(-1);
expect(nestedSegmentId).toBeTypeOf("string");
if (nestedSegmentId === undefined) return;
expect(betaIdentities[modalSlotId]).not.toBe(alphaIdentities[modalSlotId]);
expect(betaIdentities[nestedSegmentId]).not.toBe(alphaIdentities[nestedSegmentId]);
expect(alphaOtherLeafIdentities[modalSlotId]).toBe(alphaIdentities[modalSlotId]);
expect(alphaOtherLeafIdentities[nestedSegmentId]).not.toBe(
alphaIdentities[nestedSegmentId],
);
const alphaBfcacheIds = createNextBfcacheIdMap({
current: {},
currentElements: alphaElements,
elements: alphaElements,
});
const betaBfcacheIds = createNextBfcacheIdMap({
current: alphaBfcacheIds,
currentElements: alphaElements,
elements: betaElements,
});
expect(betaBfcacheIds[modalSlotId]).not.toBe(alphaBfcacheIds[modalSlotId]);
expect(betaBfcacheIds[nestedSegmentId]).not.toBe(alphaBfcacheIds[nestedSegmentId]);
} finally {
if (previousCacheComponents === undefined) {
delete process.env.__NEXT_CACHE_COMPONENTS;
} else {
process.env.__NEXT_CACHE_COMPONENTS = previousCacheComponents;
}
}
},
);
it.each([false, true])(
"retains named-slot route-group ownership with cacheComponents=%s",
(cacheComponents) => {
const previousCacheComponents = process.env.__NEXT_CACHE_COMPONENTS;
if (cacheComponents) {
process.env.__NEXT_CACHE_COMPONENTS = "true";
} else {
delete process.env.__NEXT_CACHE_COMPONENTS;
}
try {
const panelSlotId = AppElementsWire.encodeSlotId("panel", "/");
const buildElements = (id: string) =>
buildAppPageElements({
element: createElement(PageProbe),
makeThenableParams(value) {
return Promise.resolve(value);
},
matchedParams: { id },
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errors: [null],
ids: {
layouts: [],
page: "graph-page:/dashboard",
rootBoundary: "graph-root:/",
route: "graph-route:/dashboard",
routeHandler: null,
slots: { panel: "graph-slot:panel" },
templates: [],
},
layoutTreePositions: [],
layouts: [],
loading: null,
notFound: null,
notFounds: [null],
routeSegments: ["dashboard"],
slots: {
panel: {
configLayouts: [{ default: GroupLayout }],
configLayoutTreePositions: [1],
default: null,
error: null,
id: panelSlotId,
layout: { default: SlotLayout },
layoutIndex: -1,
loading: null,
name: "panel",
page: { default: SlotPage },
routeSegments: ["(shared)", "[id]"],
},
},
templateTreePositions: [],
templates: [],
},
routePath: `/dashboard/${id}`,
rootNotFoundModule: null,
});
const firstElements = buildElements("a");
const secondElements = buildElements("b");
const firstIdentities =
AppElementsWire.readMetadata(firstElements).bfcacheSegmentIdentities;
const secondIdentities =
AppElementsWire.readMetadata(secondElements).bfcacheSegmentIdentities;
const nestedSegmentIds = Object.keys(firstIdentities)
.filter((candidate) => candidate.startsWith("slot:\0vinext_bfcache_segment_"))
.sort();
const groupSegmentId = nestedSegmentIds[0];
const nestedSegmentId = nestedSegmentIds.at(-1);
expect(nestedSegmentIds).toHaveLength(2);
expect(nestedSegmentId).toBeTypeOf("string");
if (nestedSegmentId === undefined) return;
expect(firstIdentities[panelSlotId]).toBe(secondIdentities[panelSlotId]);
expect(firstIdentities[nestedSegmentId]).not.toBe(secondIdentities[nestedSegmentId]);
expect(
isValidElement(firstElements[panelSlotId]) &&
getElementTypeName(firstElements[panelSlotId].type),
).toBe("SlotLayout");
expect(findElementByTypeName(firstElements[panelSlotId], "GroupLayout")).not.toBeNull();
const firstSegmentBoundary = findElement(
firstElements[panelSlotId],
(element) =>
getElementTypeName(element.type) === "BfcacheSegmentBoundary" &&
element.props.id === groupSegmentId,
);
expect(firstSegmentBoundary?.props.stateKey).toBe("(shared)");
} finally {
if (previousCacheComponents === undefined) {
delete process.env.__NEXT_CACHE_COMPONENTS;
} else {
process.env.__NEXT_CACHE_COMPONENTS = previousCacheComponents;
}
}
},
);
it("keys a named slot's first dynamic segment without cache components", () => {
const previousCacheComponents = process.env.__NEXT_CACHE_COMPONENTS;
delete process.env.__NEXT_CACHE_COMPONENTS;
try {
const panelSlotId = AppElementsWire.encodeSlotId("panel", "/");
const buildElements = (id: string) =>
buildAppPageElements({
element: createElement(PageProbe),
makeThenableParams(value) {
return Promise.resolve(value);
},
matchedParams: { id },
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errors: [null],
layoutTreePositions: [],
layouts: [],
loading: null,
notFound: null,
notFounds: [null],
routeSegments: [],
slots: {
panel: {
default: null,
error: null,
layout: null,
layoutIndex: -1,
loading: null,
name: "panel",
page: { default: SlotPage },
routeSegments: ["[id]"],
},
},
templateTreePositions: [],
templates: [],
},
routePath: `/panel/${id}`,
rootNotFoundModule: null,
});
const firstElements = buildElements("a");
const secondElements = buildElements("b");
const firstIdentities = AppElementsWire.readMetadata(firstElements).bfcacheSegmentIdentities;
const secondIdentities =
AppElementsWire.readMetadata(secondElements).bfcacheSegmentIdentities;
const firstSegmentId = Object.keys(firstIdentities).find((id) =>
id.startsWith("slot:\0vinext_bfcache_segment_"),
);
expect(firstSegmentId).toBeTypeOf("string");
if (firstSegmentId === undefined) return;
const firstSegmentBoundary = findElement(
firstElements[panelSlotId],
(element) =>
getElementTypeName(element.type) === "BfcacheSegmentBoundary" &&
element.props.id === firstSegmentId,
);
expect(secondIdentities[panelSlotId]).toBe(firstIdentities[panelSlotId]);
expect(secondIdentities[firstSegmentId]).not.toBe(firstIdentities[firstSegmentId]);
expect(firstSegmentBoundary?.props.stateKey).toBe("id|a|d");
} finally {
if (previousCacheComponents === undefined) {
delete process.env.__NEXT_CACHE_COMPONENTS;
} else {
process.env.__NEXT_CACHE_COMPONENTS = previousCacheComponents;
}
}
});
it("keys an empty named-slot branch by its dynamic owner without cache components", () => {
const previousCacheComponents = process.env.__NEXT_CACHE_COMPONENTS;
delete process.env.__NEXT_CACHE_COMPONENTS;
try {
const panelSlotId = AppElementsWire.encodeSlotId("panel", "/");
const buildElements = (team: string) =>
buildAppPageElements({
element: createElement(PageProbe),
makeThenableParams(value) {
return Promise.resolve(value);
},
matchedParams: { team },
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errors: [null],
ids: {
layouts: [],
page: "graph-page:/[team]",
rootBoundary: "graph-root:/",
route: "graph-route:/[team]",
routeHandler: null,
slots: { panel: "graph-slot:panel" },
templates: [],
},
layoutTreePositions: [],
layouts: [],
loading: null,
notFound: null,
notFounds: [null],
routeSegments: ["[team]"],
slots: {
panel: {
default: null,
error: null,
id: panelSlotId,
layout: null,
layoutIndex: -1,
loading: null,
name: "panel",
ownerTreePosition: 1,
page: { default: SlotPage },
routeSegments: [],
},
},
templateTreePositions: [],
templates: [],
},
routePath: `/${team}`,
rootNotFoundModule: null,
});
const firstElements = buildElements("a");
const secondElements = buildElements("b");
const firstIdentities = AppElementsWire.readMetadata(firstElements).bfcacheSegmentIdentities;
const secondIdentities =
AppElementsWire.readMetadata(secondElements).bfcacheSegmentIdentities;
const branchSegmentId = Object.keys(firstIdentities).find((id) =>
id.startsWith("slot:\0vinext_bfcache_segment_"),
);
expect(branchSegmentId).toBeTypeOf("string");
if (branchSegmentId === undefined) return;
const ownerBoundary = findElement(
firstElements[panelSlotId],
(element) =>
getElementTypeName(element.type) === "BfcacheSegmentBoundary" &&
element.props.id === branchSegmentId,
);
expect(secondIdentities[panelSlotId]).not.toBe(firstIdentities[panelSlotId]);
expect(secondIdentities[branchSegmentId]).not.toBe(firstIdentities[branchSegmentId]);
expect(ownerBoundary?.props.stateKey).toBe("active");
} finally {
if (previousCacheComponents === undefined) {
delete process.env.__NEXT_CACHE_COMPONENTS;
} else {
process.env.__NEXT_CACHE_COMPONENTS = previousCacheComponents;
}
}
});
it("separates empty active and default slot branches without cache components", () => {
const previousCacheComponents = process.env.__NEXT_CACHE_COMPONENTS;
delete process.env.__NEXT_CACHE_COMPONENTS;
try {
const panelSlotId = AppElementsWire.encodeSlotId("panel", "/");
const buildElements = (active: boolean) =>
buildAppPageElements({
element: createElement(PageProbe),
makeThenableParams(value) {
return Promise.resolve(value);
},
matchedParams: {},
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errors: [null],
layoutTreePositions: [],
layouts: [],
loading: null,
notFound: null,
notFounds: [null],
routeSegments: [],
slots: {
panel: {
default: active ? null : { default: SlotPage },
error: null,
layout: null,
layoutIndex: -1,
loading: null,
name: "panel",
page: active ? { default: SlotPage } : null,
routeSegments: [],
},
},
templateTreePositions: [],
templates: [],
},
routePath: "/",
rootNotFoundModule: null,
});
const activeElements = buildElements(true);
const defaultElements = buildElements(false);
const activeIdentities =
AppElementsWire.readMetadata(activeElements).bfcacheSegmentIdentities;
const defaultIdentities =
AppElementsWire.readMetadata(defaultElements).bfcacheSegmentIdentities;
const branchSegmentId = Object.keys(activeIdentities).find((id) =>
id.startsWith("slot:\0vinext_bfcache_segment_"),
);
expect(branchSegmentId).toBeTypeOf("string");
if (branchSegmentId === undefined) return;
expect(defaultIdentities[panelSlotId]).toBe(activeIdentities[panelSlotId]);
expect(defaultIdentities[branchSegmentId]).not.toBe(activeIdentities[branchSegmentId]);
expect(
findElementByTypeName(activeElements[panelSlotId], "BfcacheSegmentBoundary")?.props
.stateKey,
).toBe("active");
expect(
findElementByTypeName(defaultElements[panelSlotId], "BfcacheSegmentBoundary")?.props
.stateKey,
).toBe("default");
} finally {
if (previousCacheComponents === undefined) {
delete process.env.__NEXT_CACHE_COMPONENTS;
} else {
process.env.__NEXT_CACHE_COMPONENTS = previousCacheComponents;
}
}
});
it("does not over-key intercepted segments by leaf route ids", () => {
const modalSlotId = AppElementsWire.encodeSlotId("modal", "/");
const buildIdentity = (
wireTargetRouteId: string,
semanticTargetRouteId: string | null,
sourceId = "a",
): string | undefined => {
const elements = buildAppPageElements({
element: createElement(PageProbe),
interception: {
sourceMatchedUrl: "/feed",
sourceRouteId: "route:/feed",
slotId: modalSlotId,
targetMatchedUrl: "/photos/42",
targetRouteId: wireTargetRouteId,
},
makeThenableParams(value) {
return Promise.resolve(value);
},
matchedParams: { id: "42", sourceId },
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errors: [null],
ids: {
layouts: ["graph-layout:root"],
page: "graph-page:/feed",
rootBoundary: "graph-root:/",
route: "graph-route:/feed/[sourceId]",
routeHandler: null,
slots: { modal: "graph-slot:modal" },
templates: [],
},
layoutTreePositions: [0],
layouts: [{ default: RootLayout }],
loading: null,
notFound: null,
notFounds: [null],
routeSegments: ["feed", "[sourceId]"],
slots: {
modal: {
default: null,
error: null,
id: "transport-independent-slot-id",
layout: null,
layoutIndex: 0,
loading: null,
name: "modal",
page: { default: SlotPage },
routeSegments: ["photos", "[id]"],
},
},
templateTreePositions: [],
templates: [],
},
routePath: "/photos/42",
rootNotFoundModule: null,
semanticInterceptionTargetRouteId: semanticTargetRouteId,
});
const identities = elements[AppElementsWire.keys.bfcacheSegmentIdentities];
if (typeof identities !== "object" || identities === null || Array.isArray(identities)) {
throw new Error("Expected BFCache identity metadata");
}
const identityId = Object.keys(identities).find((id) =>
id.startsWith("slot:\0vinext_bfcache_segment_"),
);
const identity = identityId
? (identities as Readonly<Record<string, unknown>>)[identityId]
: undefined;
if (identity !== undefined && typeof identity !== "string") {
throw new Error("Expected modal BFCache identity");
}
return identity;
};
const baseline = buildIdentity("route:/photos/42", "graph-route:/photos/[id]");
const changedWireId = buildIdentity(
"route:/transport-format-changed/photos/42",
"graph-route:/photos/[id]",
);
const changedGraphId = buildIdentity("route:/photos/42", "graph-route:/albums/[id]");
const changedSourceParams = buildIdentity("route:/photos/42", "graph-route:/photos/[id]", "b");
const missingGraphId = buildIdentity("route:/photos/42", null);
expect(changedWireId).toBe(baseline);
expect(changedGraphId).toBe(baseline);
// Next.js keys named-slot Activity from the slot's active segment. Opening
// the same interception target from another source instance must retain
// the slot identity; the parent route owns source-state separation.
expect(changedSourceParams).toBe(baseline);
expect(missingGraphId).toBeDefined();
expect(baseline).toBeDefined();
if (baseline === undefined) return;
expect(JSON.parse(baseline)).toMatchObject({
1: "graph-slot:modal",
2: "graph-layout:root",
4: null,
5: null,
});
});
it("retains an intercepted target's shared physical segment identities", () => {
const modalSlotId = AppElementsWire.encodeSlotId("modal", "/");
const buildIdentities = (
identitySegments: string[],
targetRouteId: string,
): Readonly<Record<string, string>> => {
const elements = buildAppPageElements({
element: createElement(PageProbe),
interception: {
sourceMatchedUrl: "/feed",
sourceRouteId: "route:/feed",
slotId: modalSlotId,
targetMatchedUrl: "/photo/42",
targetRouteId: "route:/photo/42",
},
makeThenableParams(value) {
return Promise.resolve(value);
},
matchedParams: {},
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errors: [null],
ids: {
layouts: ["graph-layout:root"],
page: "graph-page:/feed",
rootBoundary: "graph-root:/",
route: "graph-route:/feed",
routeHandler: null,
slots: { modal: "graph-slot:modal" },
templates: [],
},
layoutTreePositions: [0],
layouts: [{ default: RootLayout }],
loading: null,
notFound: null,
notFounds: [null],
routeSegments: ["feed"],
slots: {
modal: {
default: null,
error: null,
id: modalSlotId,
layout: null,
layoutIndex: 0,
loading: null,
name: "modal",
page: null,
routeSegments: null,
},
},
templateTreePositions: [],
templates: [],
},
routePath: "/photo/42",
rootNotFoundModule: null,
semanticInterceptionTargetRouteId: targetRouteId,
slotOverrides: {
modal: {
branchSegments: identitySegments.map((segment) => segment.replace("(.)", "")),
identitySegments: toSemanticSegments(identitySegments),
pageModule: { default: SlotPage },
params: { id: "42" },
routeSegments: identitySegments.map((segment) => segment.replace("(.)", "")),
},
},
});
const identities = elements[AppElementsWire.keys.bfcacheSegmentIdentities];
if (typeof identities !== "object" || identities === null || Array.isArray(identities)) {
throw new Error("Expected BFCache identity metadata");
}
return identities as Readonly<Record<string, string>>;
};
const base = buildIdentities(["(.)photo", "[id]"], "graph-route:/photo/[id]");
const extended = buildIdentities(
["(.)photo", "[id]", "details"],
"graph-route:/photo/[id]/details",
);
const baseSegmentIds = Object.keys(base)
.filter((id) => id.startsWith("slot:\0vinext_bfcache_segment_"))
.sort();
const extendedSegmentIds = Object.keys(extended)
.filter((id) => id.startsWith("slot:\0vinext_bfcache_segment_"))
.sort();
expect(baseSegmentIds).toHaveLength(2);
expect(extendedSegmentIds).toHaveLength(3);
expect(extended[extendedSegmentIds[0]!]).toBe(base[baseSegmentIds[0]!]);
expect(extended[extendedSegmentIds[1]!]).toBe(base[baseSegmentIds[1]!]);
expect(base[extendedSegmentIds[2]!]).toBeUndefined();
expect(extended[extendedSegmentIds[2]!]).toBeTypeOf("string");
});
it("keys synthetic children-slot page elements with their emitted identity", () => {
const childrenSlotId = AppElementsWire.encodeSlotId("children", "/");
const buildElements = (id: string) =>
buildAppPageElements({
element: createElement(PageProbe),
makeThenableParams(value) {
return Promise.resolve(value);
},
matchedParams: { id },
resolvedMetadata: null,
resolvedViewport: {},
route: {
childrenSlot: {
id: "graph-slot:children",
ownerTreePath: "/",
state: "active",
},
error: null,
errors: [null],
layoutTreePositions: [0],
layouts: [{ default: RootLayout }],
loading: null,
notFound: null,
notFounds: [null],
routeSegments: ["photos", "[id]"],
templateTreePositions: [],
templates: [],
},
routePath: `/photos/${id}`,
rootNotFoundModule: null,
});
const firstIdentity = AppElementsWire.readMetadata(buildElements("42"))
.bfcacheSegmentIdentities[childrenSlotId];
const repeatedIdentity = AppElementsWire.readMetadata(buildElements("42"))
.bfcacheSegmentIdentities[childrenSlotId];
const changedIdentity = AppElementsWire.readMetadata(buildElements("43"))
.bfcacheSegmentIdentities[childrenSlotId];
expect(firstIdentity).toBeTypeOf("string");
expect(JSON.parse(firstIdentity)[1]).toBe("graph-slot:children");
expect(repeatedIdentity).toBe(firstIdentity);
expect(changedIdentity).not.toBe(firstIdentity);
});
it("wraps intercepted slot overrides with intercept layout modules inside the slot layout", async () => {
function SlotRootLoading() {
return createElement("p", null, "Slot root loading");
}
const sidebarOverride: AppPageSlotOverride<AppPageModule> = {
layoutModules: [{ default: InterceptOuterLayout }, { default: InterceptInnerLayout }],
loadingModules: [{ default: SlotLoadingProbe }],
loadingTreePositions: [1],
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: { default: SlotRootLoading },
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("retains slot-root loading outside intercepted branch loading", () => {
function SlotRootLoading() {
return createElement("p", null, "Slot root loading");
}
function NormalBranchLoading() {
return createElement("p", null, "Normal branch loading");
}
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: ["dashboard"],
slots: {
sidebar: {
default: null,
error: null,
layout: null,
layoutIndex: -1,
loading: { default: SlotRootLoading },
loadings: [{ default: SlotRootLoading }, { default: NormalBranchLoading }],
loadingTreePositions: [0, 1],
name: "sidebar",
page: { default: SlotPage },
routeSegments: [],
},
},
templateTreePositions: [],
templates: [],
},
routePath: "/dashboard",
rootNotFoundModule: null,
slotOverrides: {
sidebar: {
loadingModules: [{ default: SlotLoadingProbe }],
loadingTreePositions: [1],
pageModule: { default: SlotPage },
routeSegments: ["photo"],
},
},
});
const slotEntry = elements[AppElementsWire.encodeSlotId("sidebar", "/")];
const rootBoundary = findSuspenseWithFallback(slotEntry, "SlotRootLoading");
const interceptBoundary = findSuspenseWithFallback(slotEntry, "SlotLoadingProbe");
expect(rootBoundary).not.toBeNull();
expect(interceptBoundary).not.toBeNull();
expect(findSuspenseWithFallback(slotEntry, "NormalBranchLoading")).toBeNull();
expect(
findSuspenseWithFallback(rootBoundary?.props.children, "SlotLoadingProbe"),
).not.toBeNull();
});
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 without its slot layout on hard navigation", async () => {
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: {
sidebar: {
default: { default: DefaultPage },
error: null,
layout: { default: SlotLayout },
layoutIndex: 0,
loading: null,
name: "sidebar",
page: null,
routeSegments: [],
},
},
templateTreePositions: [],
templates: [],
},
routePath: "/",
rootNotFoundModule: null,
});
// On hard navigation the default.tsx must render so the initial HTML is
// fully populated, but Next.js does not wrap the fallback in the slot's
// own layout because default.tsx sits beside that layout in the loader tree.
expect(elements["slot:sidebar:/"]).toBeDefined();
const html = await renderRouteEntry(elements, "route:/");
expect(html).toContain("default-slot");
expect(html).not.toContain('data-slot-layout="sidebar"');
});
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("waits for page initialization before serializing parallel slot entries", async () => {
let activeLocale = "en";
const pageRenderDependency = createAppPageRenderDependency();
function LocaleSlot() {
return createElement("aside", null, `slot:${activeLocale}`);
}
const elements = buildAppPageElements({
element: createElement("main", null, "Page content"),
makeThenableParams(params) {
return Promise.resolve(params);
},
matchedParams: {},
pageRenderDependency,
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errors: [],
layoutTreePositions: [],
layouts: [],
loading: null,
notFound: null,
notFounds: [],
routeSegments: ["dashboard"],
slots: {
sidebar: {
default: null,
error: null,
layout: null,
layoutIndex: -1,
loading: null,
name: "sidebar",
page: { default: LocaleSlot },
routeSegments: [],
},
},
templateTreePositions: [],
templates: [],
},
routePath: "/dashboard",
rootNotFoundModule: null,
});
const slotId = AppElementsWire.encodeSlotId("sidebar", "/");
let slotRenderSettled = false;
const slotHtmlPromise = renderHtml(readChildren(elements[slotId])).then((html) => {
slotRenderSettled = true;
return html;
});
await Promise.resolve();
expect(slotRenderSettled).toBe(false);
activeLocale = "de";
pageRenderDependency.release();
const html = await withTimeout(slotHtmlPromise, 1_000);
expect(html).toContain("slot:de");
expect(html).not.toContain("slot:en");
});
it("preserves parent-before-child execution under an ancestor loading boundary", async () => {
let activeLocale = "en";
async function AsyncTemplate(props: Record<string, unknown>) {
await Promise.resolve();
activeLocale = "de";
return createElement("div", null, 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,
loadings: [{ default: RouteLoadingProbe }],
loadingTreePositions: [0],
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");
});
// Ported from Next.js: test/e2e/app-dir/metadata-streaming/metadata-streaming.test.ts
// https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/app-dir/metadata-streaming/metadata-streaming.test.ts
it("wraps the streaming metadata outlet in the built-in not-found boundary", () => {
const elements = buildAppPageElements({
element: createElement(PageProbe),
makeThenableParams(params) {
return Promise.resolve(params);
},
matchedParams: {},
resolvedMetadata: null,
resolvedViewport: {},
streamingMetadataOutlet: Promise.resolve(null),
streamingMetadataOutletSuspended: true,
route: {
error: null,
errors: [null],
layoutTreePositions: [0],
layouts: [{ default: RootLayout }],
loading: null,
notFound: null,
notFounds: [null],
routeSegments: ["metadata-not-found"],
slots: {},
templateTreePositions: [],
templates: [],
},
routePath: "/metadata-not-found",
rootNotFoundModule: null,
});
const notFoundBoundary = findElementByTypeName(
elements["route:/metadata-not-found"],
"NotFoundBoundary",
);
expect(notFoundBoundary).not.toBeNull();
if (!notFoundBoundary) throw new Error("Expected the built-in not-found boundary");
expect(getElementTypeName((notFoundBoundary.props.fallback as ReactElement).type)).toBe(
"DefaultNotFound",
);
expect(
findSlotById(
notFoundBoundary.props.children,
"__vinext_streaming_metadata_outlet:route:/metadata-not-found",
),
).not.toBeNull();
});
it("owns the built-in not-found boundary at the root layout", () => {
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: RootLayout }, { default: GroupLayout }],
loading: null,
notFound: null,
notFounds: [null, null],
routeSegments: ["dashboard", "reports"],
slots: {},
templateTreePositions: [],
templates: [],
},
routePath: "/dashboard/reports",
rootNotFoundModule: null,
});
const routeEntry = elements["route:/dashboard/reports"];
const rootLayoutSlot = findSlotById(routeEntry, "layout:/");
const nestedLayoutSlot = findSlotById(routeEntry, "layout:/dashboard");
expect(rootLayoutSlot).not.toBeNull();
expect(nestedLayoutSlot).not.toBeNull();
if (!rootLayoutSlot || !nestedLayoutSlot) {
throw new Error("Expected both root and nested layout slots");
}
const rootNotFoundBoundary = findElementByTypeName(
rootLayoutSlot.props.children,
"NotFoundBoundary",
);
expect(rootNotFoundBoundary).not.toBeNull();
if (!rootNotFoundBoundary) throw new Error("Expected the built-in not-found boundary");
expect(getElementTypeName((rootNotFoundBoundary.props.fallback as ReactElement).type)).toBe(
"DefaultNotFound",
);
expect(findSlotById(rootNotFoundBoundary.props.children, "layout:/dashboard")).not.toBeNull();
expect(findElementByTypeName(nestedLayoutSlot.props.children, "NotFoundBoundary")).toBeNull();
});
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 buildElements = (slug: string) =>
buildAppPageElements({
element: createElement(PageProbe),
makeThenableParams(params) {
return Promise.resolve(params);
},
matchedParams: { slug },
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errors: [null, null],
ids: {
layouts: ["graph-layout:/", "graph-layout:/docs"],
page: "graph-page:/docs/[slug]",
rootBoundary: "graph-root:/",
route: "graph-route:/docs/[slug]",
routeHandler: null,
slots: {},
templates: ["graph-template:/docs"],
},
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/${slug}`,
rootNotFoundModule: null,
});
const elements = buildElements("launch");
const templateSlot = findSlotById(elements["route:/docs/launch"], "template:/docs");
expect(templateSlot).not.toBeNull();
expect(templateSlot?.key).toBe(JSON.stringify(["docs", "slug|launch|d"]));
expect(
AppElementsWire.readMetadata(buildElements("release")).bfcacheSegmentIdentities[
"template:/docs"
],
).not.toBe(AppElementsWire.readMetadata(elements).bfcacheSegmentIdentities["template:/docs"]);
const buildTenantElements = (tenant: string) =>
buildAppPageElements({
element: createElement(PageProbe),
makeThenableParams(params) {
return Promise.resolve(params);
},
matchedParams: { tenant },
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errors: [null, null],
ids: {
layouts: ["graph-layout:/", "graph-layout:/[tenant]"],
page: "graph-page:/[tenant]/settings",
rootBoundary: "graph-root:/",
route: "graph-route:/[tenant]/settings",
routeHandler: null,
slots: {},
templates: ["graph-template:/[tenant]"],
},
layoutTreePositions: [0, 1],
layouts: [{ default: RootLayout }, { default: GroupLayout }],
loading: null,
notFound: null,
notFounds: [null, null],
routeSegments: ["[tenant]", "settings"],
slots: {},
templateTreePositions: [1],
templates: [{ default: LeafTemplate }],
},
routePath: `/${tenant}/settings`,
rootNotFoundModule: null,
});
const tenantAIdentity = AppElementsWire.readMetadata(buildTenantElements("a"))
.bfcacheSegmentIdentities["template:/[tenant]"];
const tenantBIdentity = AppElementsWire.readMetadata(buildTenantElements("b"))
.bfcacheSegmentIdentities["template:/[tenant]"];
expect(tenantAIdentity).toBeTypeOf("string");
expect(tenantBIdentity).not.toBe(tenantAIdentity);
const buildGroupedElements = (group: string, id: string) =>
buildAppPageElements({
element: createElement(PageProbe),
makeThenableParams(params) {
return Promise.resolve(params);
},
matchedParams: { id },
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errors: [null],
ids: {
layouts: ["graph-layout:/"],
page: `graph-page:/${group}/[id]`,
rootBoundary: "graph-root:/",
route: `graph-route:/${group}/[id]`,
routeHandler: null,
slots: {},
templates: ["graph-template:/"],
},
layoutTreePositions: [0],
layouts: [{ default: RootLayout }],
loading: null,
notFound: null,
notFounds: [null],
routeSegments: [group, "[id]"],
slots: {},
templateTreePositions: [0],
templates: [{ default: LeafTemplate }],
},
routePath: `/${id}`,
rootNotFoundModule: null,
});
const groupedAIdentity = AppElementsWire.readMetadata(buildGroupedElements("(stable)", "a"))
.bfcacheSegmentIdentities["template:/"];
const groupedBIdentity = AppElementsWire.readMetadata(buildGroupedElements("(stable)", "b"))
.bfcacheSegmentIdentities["template:/"];
const otherGroupIdentity = AppElementsWire.readMetadata(buildGroupedElements("(other)", "a"))
.bfcacheSegmentIdentities["template:/"];
expect(groupedBIdentity).toBe(groupedAIdentity);
expect(otherGroupIdentity).not.toBe(groupedAIdentity);
const previousCacheComponents = process.env.__NEXT_CACHE_COMPONENTS;
process.env.__NEXT_CACHE_COMPONENTS = "true";
try {
const cachedElements = buildElements("launch");
expect(findSlotById(cachedElements["route:/docs/launch"], "template:/docs")?.key).toBeNull();
} finally {
if (previousCacheComponents === undefined) {
delete process.env.__NEXT_CACHE_COMPONENTS;
} else {
process.env.__NEXT_CACHE_COMPONENTS = previousCacheComponents;
}
}
});
it("nests per-segment loading boundaries around slow child layouts without duplicating the leaf", () => {
function ParentLoading() {
return createElement("p", null, "Loading layout");
}
function LeafLoading() {
return createElement("p", null, "Loading page");
}
const elements = buildAppPageElements({
element: createElement(PageProbe),
makeThenableParams(params) {
return Promise.resolve(params);
},
matchedParams: {},
resolvedMetadata: null,
resolvedViewport: {},
route: {
error: null,
errors: [null, null],
layoutTreePositions: [0, 2],
layouts: [{ default: RootLayout }, { default: GroupLayout }],
loading: { default: LeafLoading },
loadings: [{ default: ParentLoading }, { default: LeafLoading }],
loadingTreePositions: [1, 2],
notFound: null,
notFounds: [null, null],
routeSegments: ["parent", "slow"],
slots: {},
templateTreePositions: [],
templates: [],
},
routePath: "/parent/slow",
rootNotFoundModule: null,
});
const routeEntry = elements["route:/parent/slow"];
const parentBoundary = findSuspenseWithFallback(routeEntry, "ParentLoading");
const leafBoundary = findSuspenseWithFallback(routeEntry, "LeafLoading");
expect(parentBoundary?.key).toBe("slow");
expect(leafBoundary?.key).toBe(JSON.stringify(["parent", "slow"]));
expect(findSuspenseWithFallback(parentBoundary?.props.children, "LeafLoading")).not.toBeNull();
expect(findSlotById(parentBoundary?.props.children, "layout:/parent/slow")).not.toBeNull();
expect(countSuspenseWithFallback(routeEntry, "LeafLoading")).toBe(1);
});
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);
});
});