Files
cloudflare__vinext/tests/app-optimistic-routing.test.ts
T
James Anderson 18e618c53e fix(app-router): preserve primary parallel route slot (#2347)
* fix(app-router): preserve primary parallel route slot

* test(app-router): match parallel default layout parity

* fix(app-router): prefer nearest parallel children owner

* fix(app-router): preserve mounted slot state after HMR

* test(app-router): wait for selected parallel slot before HMR
2026-06-28 22:59:48 +00:00

645 lines
18 KiB
TypeScript

import { describe, expect, it } from "vite-plus/test";
import { createElement, Suspense } from "react";
import {
AppElementsWire,
APP_PREFETCH_LOADING_SHELL_MARKER_KEY,
type AppElements,
} from "../packages/vinext/src/server/app-elements.js";
import {
createOptimisticRouteElements,
createOptimisticRouteTemplate,
getOptimisticPrefetchSourceKey,
getOptimisticRouteTemplateKey,
matchOptimisticRouteManifestRoute,
resolveOptimisticNavigationPayload,
type OptimisticRouteTemplate,
} from "../packages/vinext/src/server/app-optimistic-routing.js";
import type {
GraphVersion,
RouteManifest,
RouteManifestRoute,
RouteManifestSlotBinding,
} from "../packages/vinext/src/routing/app-route-graph.js";
function route(input: {
id: string;
isDynamic: boolean;
paramNames?: readonly string[];
pattern: string;
patternParts: readonly string[];
slotIds?: readonly string[];
}): RouteManifestRoute {
return {
id: input.id,
isDynamic: input.isDynamic,
layoutIds: ["layout:/"],
pageId: `page:${input.pattern}`,
paramNames: [...(input.paramNames ?? [])],
pattern: input.pattern,
patternParts: [...input.patternParts],
rootBoundaryId: null,
rootParamNames: [],
routeHandlerId: null,
slotIds: [...(input.slotIds ?? [])],
templateIds: [],
};
}
function manifest(
routes: readonly RouteManifestRoute[],
slotBindings: readonly RouteManifestSlotBinding[] = [],
): RouteManifest {
return {
graphVersion: "graph:test" as GraphVersion,
segmentGraph: {
boundaries: new Map(),
defaults: new Map(),
interceptions: new Map(),
interceptionsBySlotId: new Map(),
layouts: new Map(),
pages: new Map(),
rootBoundaries: new Map(),
routeHandlers: new Map(),
routes: new Map(routes.map((entry) => [entry.id, entry])),
slotBindings: new Map(slotBindings.map((entry) => [entry.id, entry])),
slots: new Map(),
templates: new Map(),
},
};
}
function blogManifest(): RouteManifest {
return manifest([
route({
id: "route:/blog/featured",
isDynamic: false,
pattern: "/blog/featured",
patternParts: ["blog", "featured"],
}),
route({
id: "route:/blog/:slug",
isDynamic: true,
paramNames: ["slug"],
pattern: "/blog/:slug",
patternParts: ["blog", ":slug"],
}),
]);
}
function dashboardManifestWithoutProfile(): RouteManifest {
return manifest([
route({
id: "route:/dashboard/settings",
isDynamic: false,
pattern: "/dashboard/settings",
patternParts: ["dashboard", "settings"],
}),
route({
id: "route:/dashboard/:catchall+",
isDynamic: true,
paramNames: ["catchall"],
pattern: "/dashboard/:catchall+",
patternParts: ["dashboard", ":catchall+"],
}),
]);
}
function createBlogElements(): AppElements {
const routeId = AppElementsWire.encodeRouteId("/blog/post-1", null);
const pageId = AppElementsWire.encodePageId("/blog/post-1", null);
return {
...AppElementsWire.createMetadataEntries({
interceptionContext: null,
layoutIds: ["layout:/"],
rootLayoutTreePath: "/",
routeId,
}),
[pageId]: createElement("article", null, "Post 1"),
[routeId]: createElement(
Suspense,
{ fallback: createElement("p", { id: "loading-message" }, "Loading...") },
createElement("main", null, "Page slot"),
),
};
}
function createBlogLoadingShellElements(): AppElements {
const routeId = AppElementsWire.encodeRouteId("/blog/post-1", null);
const pageId = AppElementsWire.encodePageId("/blog/post-1", null);
return {
...AppElementsWire.createMetadataEntries({
interceptionContext: null,
layoutIds: ["layout:/"],
rootLayoutTreePath: "/",
routeId,
}),
[APP_PREFETCH_LOADING_SHELL_MARKER_KEY]: "LoadingBoundary",
[pageId]: null,
[routeId]: createElement("p", { id: "loading-message" }, "Loading post-1..."),
};
}
function staticSettingsManifest(): RouteManifest {
return manifest([
route({
id: "route:/settings",
isDynamic: false,
pattern: "/settings",
patternParts: ["settings"],
}),
]);
}
function createSettingsLoadingShellElements(): AppElements {
const routeId = AppElementsWire.encodeRouteId("/settings", null);
const pageId = AppElementsWire.encodePageId("/settings", null);
return {
...AppElementsWire.createMetadataEntries({
interceptionContext: null,
layoutIds: ["layout:/"],
rootLayoutTreePath: "/",
routeId,
}),
[APP_PREFETCH_LOADING_SHELL_MARKER_KEY]: "LoadingBoundary",
[pageId]: null,
[routeId]: createElement("p", { id: "loading-message" }, "Loading settings..."),
};
}
describe("App Router optimistic routing", () => {
it("matches dynamic route params while keeping static siblings authoritative", () => {
const routes = blogManifest();
expect(
matchOptimisticRouteManifestRoute({
basePath: "",
href: "/blog/post-1.rsc?_rsc=abc",
routeManifest: routes,
}),
).toMatchObject({
params: { slug: "post-1" },
route: { id: "route:/blog/:slug" },
});
expect(
matchOptimisticRouteManifestRoute({
basePath: "",
href: "/blog/featured",
routeManifest: routes,
})?.route.id,
).toBe("route:/blog/featured");
});
it("preserves dynamic route param key order", () => {
const twoSegment = manifest([
route({
id: "route:/:category/:id",
isDynamic: true,
paramNames: ["category", "id"],
pattern: "/:category/:id",
patternParts: [":category", ":id"],
}),
]);
const twoMatch = matchOptimisticRouteManifestRoute({
basePath: "",
href: "/electronics/123",
routeManifest: twoSegment,
});
expect(twoMatch).not.toBeNull();
expect(Object.keys(twoMatch!.params)).toEqual(["category", "id"]);
const threeSegment = manifest([
route({
id: "route:/:a/:b/:c",
isDynamic: true,
paramNames: ["a", "b", "c"],
pattern: "/:a/:b/:c",
patternParts: [":a", ":b", ":c"],
}),
]);
const threeMatch = matchOptimisticRouteManifestRoute({
basePath: "",
href: "/x/y/z",
routeManifest: threeSegment,
});
expect(threeMatch).not.toBeNull();
expect(Object.keys(threeMatch!.params)).toEqual(["a", "b", "c"]);
});
it("does not fall through from a known static subtree to a catch-all sibling", () => {
expect(
matchOptimisticRouteManifestRoute({
basePath: "",
href: "/dashboard/settings/profile",
routeManifest: dashboardManifestWithoutProfile(),
}),
).toBeNull();
});
it("creates loading-only optimistic elements from a learned dynamic route template", () => {
const routeManifest = blogManifest();
const elements = createBlogElements();
const template = createOptimisticRouteTemplate({
basePath: "",
elements,
href: "/blog/post-1.rsc?_rsc=abc",
interceptionContext: null,
mountedSlotsHeader: null,
routeManifest,
});
expect(template).toMatchObject<Partial<OptimisticRouteTemplate>>({
routeId: "route:/blog/:slug",
});
if (template === null) {
throw new Error("Expected optimistic route template");
}
const pageId = AppElementsWire.encodePageId("/blog/post-1", null);
const optimisticElements = createOptimisticRouteElements(template);
expect(optimisticElements[pageId]).not.toBe(elements[pageId]);
const navigationPayload = resolveOptimisticNavigationPayload({
basePath: "",
href: "/blog/post-2",
interceptionContext: null,
mountedSlotsHeader: null,
routeManifest,
templates: new Map([
[
getOptimisticRouteTemplateKey({
interceptionContext: null,
mountedSlotsHeader: null,
routeId: template.routeId,
}),
template,
],
]),
});
expect(navigationPayload?.params).toEqual({ slug: "post-2" });
expect(navigationPayload?.elements[pageId]).not.toBe(elements[pageId]);
});
it("includes active parallel slot params in optimistic navigation payloads", () => {
// Mirrors the immediate pre-dynamic-render assertion in Next.js:
// test/e2e/app-dir/parallel-route-navigations/parallel-route-navigations.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/parallel-route-navigations/parallel-route-navigations.test.ts
const slotId = "slot:/[teamID]/@slot";
const routeManifest = manifest(
[
route({
id: "route:/:teamID/sub/:folder",
isDynamic: true,
paramNames: ["teamID", "folder"],
pattern: "/:teamID/sub/:folder",
patternParts: [":teamID", "sub", ":folder"],
slotIds: [slotId],
}),
],
[
{
defaultId: null,
id: "route:/:teamID/sub/:folder::slot:/[teamID]/@slot",
ownerLayoutId: "layout:/[teamID]",
routeId: "route:/:teamID/sub/:folder",
routeSegments: ["[...catchAll]"],
slotId,
slotParamNames: ["teamID", "catchAll"],
slotPatternParts: [":teamID", ":catchAll+"],
state: "active",
},
],
);
const elements = createBlogLoadingShellElements();
const template = createOptimisticRouteTemplate({
allowLoadingShell: true,
basePath: "",
elements,
href: "/vercel/sub/folder.rsc",
interceptionContext: null,
mountedSlotsHeader: null,
routeManifest,
});
if (template === null) {
throw new Error("Expected optimistic route template");
}
const navigationPayload = resolveOptimisticNavigationPayload({
basePath: "",
href: "/vercel/sub/other-folder",
interceptionContext: null,
mountedSlotsHeader: null,
routeManifest,
templates: new Map([
[
getOptimisticRouteTemplateKey({
interceptionContext: null,
mountedSlotsHeader: null,
routeId: template.routeId,
}),
template,
],
]),
});
expect(navigationPayload?.params).toEqual({
teamID: "vercel",
folder: "other-folder",
catchAll: ["sub", "other-folder"],
});
});
it("learns optimistic templates from an implicit children slot", () => {
const childrenSlotId = "slot:children:/blog";
const routeManifest = manifest([
route({
id: "route:/blog/:slug",
isDynamic: true,
paramNames: ["slug"],
pattern: "/blog/:slug",
patternParts: ["blog", ":slug"],
slotIds: [childrenSlotId],
}),
]);
const routeId = AppElementsWire.encodeRouteId("/blog/post-1", null);
const elements: AppElements = {
...AppElementsWire.createMetadataEntries({
interceptionContext: null,
layoutIds: ["layout:/", "layout:/blog"],
rootLayoutTreePath: "/",
routeId,
}),
[childrenSlotId]: createElement("article", null, "Post 1"),
[routeId]: createElement(
Suspense,
{ fallback: createElement("p", null, "Loading") },
createElement("main", null, "Route"),
),
};
const template = createOptimisticRouteTemplate({
basePath: "",
elements,
href: "/blog/post-1.rsc",
interceptionContext: null,
mountedSlotsHeader: null,
routeManifest,
});
expect(template?.pageElementIds).toEqual([childrenSlotId]);
expect(createOptimisticRouteElements(template!)[childrenSlotId]).not.toBe(
elements[childrenSlotId],
);
});
it("does not learn routes without a loading boundary", () => {
const routeManifest = blogManifest();
const routeId = AppElementsWire.encodeRouteId("/blog/post-1", null);
const elements: AppElements = {
...AppElementsWire.createMetadataEntries({
interceptionContext: null,
layoutIds: ["layout:/"],
rootLayoutTreePath: "/",
routeId,
}),
[routeId]: createElement("main", null, "No loading boundary"),
};
expect(
createOptimisticRouteTemplate({
basePath: "",
elements,
href: "/blog/post-1.rsc",
interceptionContext: null,
mountedSlotsHeader: null,
routeManifest,
}),
).toBeNull();
expect(
createOptimisticRouteTemplate({
allowLoadingShell: true,
basePath: "",
elements: { ...elements, [routeId]: null },
href: "/blog/post-1.rsc",
interceptionContext: null,
mountedSlotsHeader: null,
routeManifest,
}),
).toBeNull();
expect(
createOptimisticRouteTemplate({
allowLoadingShell: true,
basePath: "",
elements: { ...elements, [AppElementsWire.encodePageId("/blog/post-1", null)]: null },
href: "/blog/post-1.rsc",
interceptionContext: null,
mountedSlotsHeader: null,
routeManifest,
}),
).toBeNull();
});
it("learns dynamic route templates from loading-shell prefetch payloads only when allowed", () => {
const routeManifest = blogManifest();
const elements = createBlogLoadingShellElements();
expect(
createOptimisticRouteTemplate({
basePath: "",
elements,
href: "/blog/post-1.rsc",
interceptionContext: null,
mountedSlotsHeader: null,
routeManifest,
}),
).toBeNull();
const template = createOptimisticRouteTemplate({
allowLoadingShell: true,
basePath: "",
elements,
href: "/blog/post-1.rsc",
interceptionContext: null,
mountedSlotsHeader: null,
routeManifest,
});
expect(template).toMatchObject<Partial<OptimisticRouteTemplate>>({
pageElementIds: [AppElementsWire.encodePageId("/blog/post-1", null)],
routeId: "route:/blog/:slug",
});
});
it("learns static route templates from loading-shell prefetch payloads", () => {
const routeManifest = staticSettingsManifest();
const template = createOptimisticRouteTemplate({
allowLoadingShell: true,
basePath: "",
elements: createSettingsLoadingShellElements(),
href: "/settings.rsc",
interceptionContext: null,
mountedSlotsHeader: null,
routeManifest,
});
expect(template).toMatchObject<Partial<OptimisticRouteTemplate>>({
pageElementIds: [AppElementsWire.encodePageId("/settings", null)],
routeId: "route:/settings",
});
if (template === null) {
throw new Error("Expected optimistic route template");
}
const navigationPayload = resolveOptimisticNavigationPayload({
basePath: "",
href: "/settings?tab=billing",
interceptionContext: null,
mountedSlotsHeader: null,
routeManifest,
templates: new Map([
[
getOptimisticRouteTemplateKey({
interceptionContext: null,
mountedSlotsHeader: null,
routeId: template.routeId,
}),
template,
],
]),
});
expect(navigationPayload?.params).toEqual({});
expect(navigationPayload?.template).toBe(template);
});
it("keeps learned templates distinct across mounted slot headers", () => {
const routeManifest = blogManifest();
const slotATemplate = createOptimisticRouteTemplate({
allowLoadingShell: true,
basePath: "",
elements: createBlogLoadingShellElements(),
href: "/blog/post-1.rsc",
interceptionContext: null,
mountedSlotsHeader: "modal",
routeManifest,
});
const slotBTemplate = createOptimisticRouteTemplate({
allowLoadingShell: true,
basePath: "",
elements: createBlogLoadingShellElements(),
href: "/blog/post-2.rsc",
interceptionContext: null,
mountedSlotsHeader: "drawer",
routeManifest,
});
if (slotATemplate === null || slotBTemplate === null) {
throw new Error("Expected optimistic route templates");
}
const templates = new Map([
[
getOptimisticRouteTemplateKey({
interceptionContext: null,
mountedSlotsHeader: "modal",
routeId: slotATemplate.routeId,
}),
slotATemplate,
],
[
getOptimisticRouteTemplateKey({
interceptionContext: null,
mountedSlotsHeader: "drawer",
routeId: slotBTemplate.routeId,
}),
slotBTemplate,
],
]);
expect(
resolveOptimisticNavigationPayload({
basePath: "",
href: "/blog/post-3",
interceptionContext: null,
mountedSlotsHeader: "modal",
routeManifest,
templates,
})?.template,
).toBe(slotATemplate);
expect(
resolveOptimisticNavigationPayload({
basePath: "",
href: "/blog/post-3",
interceptionContext: null,
mountedSlotsHeader: "drawer",
routeManifest,
templates,
})?.template,
).toBe(slotBTemplate);
});
it("scopes prefetch source learning by current router context", () => {
const cacheKey = "/blog/post-1.rsc\0/feed";
expect(
getOptimisticPrefetchSourceKey({
cacheKey,
interceptionContext: "/feed",
mountedSlotsHeader: "modal",
}),
).not.toBe(
getOptimisticPrefetchSourceKey({
cacheKey,
interceptionContext: "/gallery",
mountedSlotsHeader: "modal",
}),
);
expect(
getOptimisticPrefetchSourceKey({
cacheKey,
interceptionContext: "/feed",
mountedSlotsHeader: "modal",
}),
).not.toBe(
getOptimisticPrefetchSourceKey({
cacheKey,
interceptionContext: "/feed",
mountedSlotsHeader: "drawer",
}),
);
});
it("does not learn or resolve optimistic payloads for intercepted contexts", () => {
const routeManifest = blogManifest();
const elements = createBlogLoadingShellElements();
const template = createOptimisticRouteTemplate({
allowLoadingShell: true,
basePath: "",
elements,
href: "/blog/post-1.rsc",
interceptionContext: "/feed",
mountedSlotsHeader: null,
routeManifest,
});
expect(template).toBeNull();
expect(
resolveOptimisticNavigationPayload({
basePath: "",
href: "/blog/post-2",
interceptionContext: "/feed",
mountedSlotsHeader: null,
routeManifest,
templates: new Map(),
}),
).toBeNull();
});
});