Files
cloudflare__vinext/tests/app-optimistic-routing.test.ts
T
Nathan Nguyen 82b6388ece fix(app-router): render optimistic loading shells for dynamic navigation (#1421)
* fix(app-router): render optimistic loading shells for dynamic navigation

Dynamic App Router links currently skip automatic prefetch for route patterns with params, so a navigation to a sibling dynamic URL waits for fresh RSC data before committing anything. That diverges from Next.js, where automatic prefetch learns the loading shell for dynamic routes and the router can commit the shell immediately while the full payload is fetched.

The client prefetch path now requests a loading-shell render for dynamic app routes, keeps that entry out of the navigation cache, learns reusable optimistic route templates from settled shell prefetches, and commits a detached optimistic payload for matching dynamic navigations. The server emits shell-only RSC payloads under a separate cache variant so normal navigation data is not polluted.

Tests cover matching semantics, shell-only page wiring, cache key isolation, prefetch cache consumption, Link prefetch behavior, and the upstream Next.js optimistic-routing deploy-suite case.

* chore(app-router): address optimistic routing review
2026-05-22 10:07:26 +01:00

420 lines
12 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,
} from "../packages/vinext/src/routing/app-route-graph.js";
function route(input: {
id: string;
isDynamic: boolean;
paramNames?: readonly string[];
pattern: string;
patternParts: 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: [],
templateIds: [],
};
}
function manifest(routes: readonly RouteManifestRoute[]): 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(),
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..."),
};
}
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("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("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("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();
});
});