mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
2b9ff64019
App Router dynamic-route params were being returned in reversed key order (deepest segment first) because the recursive trie matcher mutated result.params on the way back up the call stack. Fix by collecting param entries into a shared stack during forward traversal, then materializing the params object from that stack at the terminal node. This preserves declaration order for Object.keys(). - Shared buildParams helper extracted to routing/utils.ts to unify code between route-trie.ts and app-optimistic-routing.ts. - Added tests verifying key order for [category]/[id], [a]/[b]/[c], mixed static+dynamic, and backtracking-to-catch-all scenarios. Fixes #1537
458 lines
13 KiB
TypeScript
458 lines
13 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("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("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();
|
|
});
|
|
});
|