-
Notifications
You must be signed in to change notification settings - Fork 324
feat(router): expose route graph manifest read model #1089
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -6,6 +6,7 @@ | |||||||||||||||
| */ | ||||||||||||||||
| import path from "node:path"; | ||||||||||||||||
| import fs from "node:fs"; | ||||||||||||||||
| import { createHash } from "node:crypto"; | ||||||||||||||||
| import { compareRoutes, decodeRouteSegment } from "./utils.js"; | ||||||||||||||||
| import { scanWithExtensions, type ValidFileMatcher } from "./file-matcher.js"; | ||||||||||||||||
| import { validateRoutePatterns } from "./route-validation.js"; | ||||||||||||||||
|
|
@@ -151,6 +152,7 @@ export type AppRouteSemanticIds = { | |||||||||||||||
| route: string; | ||||||||||||||||
| page: string | null; | ||||||||||||||||
| routeHandler: string | null; | ||||||||||||||||
| rootBoundary: RootBoundaryId | null; | ||||||||||||||||
| layouts: readonly string[]; | ||||||||||||||||
| templates: readonly string[]; | ||||||||||||||||
| /** | ||||||||||||||||
|
|
@@ -164,9 +166,81 @@ export type AppRouteGraphParallelSlot = ParallelSlot & { | |||||||||||||||
| id: string; | ||||||||||||||||
| }; | ||||||||||||||||
|
|
||||||||||||||||
| export type AppRouteGraphRoute = Omit<AppRoute, "ids" | "parallelSlots"> & { | ||||||||||||||||
| export type AppRouteGraphRoute = Omit<AppRoute, "ids" | "parallelSlots" | "rootParamNames"> & { | ||||||||||||||||
| ids: AppRouteSemanticIds; | ||||||||||||||||
| parallelSlots: AppRouteGraphParallelSlot[]; | ||||||||||||||||
| rootParamNames: string[]; | ||||||||||||||||
| }; | ||||||||||||||||
|
|
||||||||||||||||
| type Flavor<T, Brand extends string> = T & { readonly __flavor?: Brand }; | ||||||||||||||||
|
|
||||||||||||||||
| export type GraphVersion = Flavor<string, "GraphVersion">; | ||||||||||||||||
| export type RootBoundaryId = Flavor<string, "RootBoundaryId">; | ||||||||||||||||
|
|
||||||||||||||||
| export type RouteManifestRoute = { | ||||||||||||||||
| id: string; | ||||||||||||||||
| pattern: string; | ||||||||||||||||
| patternParts: readonly string[]; | ||||||||||||||||
| isDynamic: boolean; | ||||||||||||||||
| paramNames: readonly string[]; | ||||||||||||||||
| rootParamNames: readonly string[]; | ||||||||||||||||
| rootBoundaryId: RootBoundaryId | null; | ||||||||||||||||
| pageId: string | null; | ||||||||||||||||
| routeHandlerId: string | null; | ||||||||||||||||
| layoutIds: readonly string[]; | ||||||||||||||||
| templateIds: readonly string[]; | ||||||||||||||||
| slotIds: readonly string[]; | ||||||||||||||||
| }; | ||||||||||||||||
|
|
||||||||||||||||
| export type RouteManifestPage = { | ||||||||||||||||
| id: string; | ||||||||||||||||
| routeId: string; | ||||||||||||||||
| pattern: string; | ||||||||||||||||
| }; | ||||||||||||||||
|
|
||||||||||||||||
| export type RouteManifestRouteHandler = { | ||||||||||||||||
| id: string; | ||||||||||||||||
| routeId: string; | ||||||||||||||||
| pattern: string; | ||||||||||||||||
| }; | ||||||||||||||||
|
|
||||||||||||||||
| export type RouteManifestLayout = { | ||||||||||||||||
| id: string; | ||||||||||||||||
| treePath: string; | ||||||||||||||||
| rootBoundaryId: RootBoundaryId | null; | ||||||||||||||||
| }; | ||||||||||||||||
|
|
||||||||||||||||
| export type RouteManifestTemplate = { | ||||||||||||||||
| id: string; | ||||||||||||||||
| treePath: string; | ||||||||||||||||
| rootBoundaryId: RootBoundaryId | null; | ||||||||||||||||
| }; | ||||||||||||||||
|
|
||||||||||||||||
| export type RouteManifestSlot = { | ||||||||||||||||
| id: string; | ||||||||||||||||
| key: string; | ||||||||||||||||
| name: string; | ||||||||||||||||
| }; | ||||||||||||||||
|
|
||||||||||||||||
| export type RouteManifestRootBoundary = { | ||||||||||||||||
| id: RootBoundaryId; | ||||||||||||||||
| layoutId: string; | ||||||||||||||||
| treePath: string; | ||||||||||||||||
| }; | ||||||||||||||||
|
|
||||||||||||||||
| export type StaticSegmentGraph = { | ||||||||||||||||
| routes: ReadonlyMap<string, RouteManifestRoute>; | ||||||||||||||||
| pages: ReadonlyMap<string, RouteManifestPage>; | ||||||||||||||||
| routeHandlers: ReadonlyMap<string, RouteManifestRouteHandler>; | ||||||||||||||||
| layouts: ReadonlyMap<string, RouteManifestLayout>; | ||||||||||||||||
| templates: ReadonlyMap<string, RouteManifestTemplate>; | ||||||||||||||||
| slots: ReadonlyMap<string, RouteManifestSlot>; | ||||||||||||||||
| rootBoundaries: ReadonlyMap<RootBoundaryId, RouteManifestRootBoundary>; | ||||||||||||||||
| }; | ||||||||||||||||
|
|
||||||||||||||||
| export type RouteManifest = { | ||||||||||||||||
| graphVersion: GraphVersion; | ||||||||||||||||
| segmentGraph: StaticSegmentGraph; | ||||||||||||||||
| }; | ||||||||||||||||
|
|
||||||||||||||||
| function createAppRouteGraphRouteId(pattern: string): string { | ||||||||||||||||
|
|
@@ -193,10 +267,184 @@ function createAppRouteGraphSlotId(slotName: string, ownerTreePath: string): str | |||||||||||||||
| return `slot:${slotName}:${ownerTreePath}`; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| function createAppRouteGraphRootBoundaryId(treePath: string): RootBoundaryId { | ||||||||||||||||
| return `root-boundary:${treePath}`; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| function compareStableStrings(left: string, right: string): number { | ||||||||||||||||
| if (left < right) return -1; | ||||||||||||||||
| if (left > right) return 1; | ||||||||||||||||
| return 0; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| function sortedMapValues<T>(map: ReadonlyMap<string, T>): T[] { | ||||||||||||||||
| return Array.from(map.entries()) | ||||||||||||||||
| .sort(([left], [right]) => compareStableStrings(left, right)) | ||||||||||||||||
| .map(([, value]) => value); | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| function createRouteManifest(routes: readonly AppRouteGraphRoute[]): RouteManifest { | ||||||||||||||||
| const segmentGraph = createStaticSegmentGraph(routes); | ||||||||||||||||
|
|
||||||||||||||||
| return { | ||||||||||||||||
| graphVersion: createRouteManifestGraphVersion(segmentGraph), | ||||||||||||||||
| segmentGraph, | ||||||||||||||||
| }; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| function createStaticSegmentGraph(routes: readonly AppRouteGraphRoute[]): StaticSegmentGraph { | ||||||||||||||||
| const routeEntries = new Map<string, RouteManifestRoute>(); | ||||||||||||||||
| const pages = new Map<string, RouteManifestPage>(); | ||||||||||||||||
| const routeHandlers = new Map<string, RouteManifestRouteHandler>(); | ||||||||||||||||
| const layouts = new Map<string, RouteManifestLayout>(); | ||||||||||||||||
| const templates = new Map<string, RouteManifestTemplate>(); | ||||||||||||||||
| const slots = new Map<string, RouteManifestSlot>(); | ||||||||||||||||
| const rootBoundaries = new Map<RootBoundaryId, RouteManifestRootBoundary>(); | ||||||||||||||||
|
|
||||||||||||||||
| for (const route of routes) { | ||||||||||||||||
| routeEntries.set(route.ids.route, { | ||||||||||||||||
| id: route.ids.route, | ||||||||||||||||
| pattern: route.pattern, | ||||||||||||||||
| patternParts: [...route.patternParts], | ||||||||||||||||
| isDynamic: route.isDynamic, | ||||||||||||||||
| paramNames: [...route.params], | ||||||||||||||||
| rootParamNames: [...route.rootParamNames], | ||||||||||||||||
| rootBoundaryId: route.ids.rootBoundary, | ||||||||||||||||
| pageId: route.ids.page, | ||||||||||||||||
| routeHandlerId: route.ids.routeHandler, | ||||||||||||||||
| layoutIds: [...route.ids.layouts], | ||||||||||||||||
| templateIds: [...route.ids.templates], | ||||||||||||||||
| slotIds: route.parallelSlots.map((slot) => slot.id).sort(compareStableStrings), | ||||||||||||||||
| }); | ||||||||||||||||
|
|
||||||||||||||||
| if (route.ids.page) { | ||||||||||||||||
| pages.set(route.ids.page, { | ||||||||||||||||
| id: route.ids.page, | ||||||||||||||||
| routeId: route.ids.route, | ||||||||||||||||
| pattern: route.pattern, | ||||||||||||||||
| }); | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| if (route.ids.routeHandler) { | ||||||||||||||||
| routeHandlers.set(route.ids.routeHandler, { | ||||||||||||||||
| id: route.ids.routeHandler, | ||||||||||||||||
| routeId: route.ids.route, | ||||||||||||||||
| pattern: route.pattern, | ||||||||||||||||
| }); | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| for (const [index, layoutId] of route.ids.layouts.entries()) { | ||||||||||||||||
| const treePosition = route.layoutTreePositions[index]; | ||||||||||||||||
| assertRouteManifestTreePosition("layout", route, layoutId, treePosition); | ||||||||||||||||
|
|
||||||||||||||||
| const treePath = createAppRouteGraphTreePath(route.routeSegments, treePosition); | ||||||||||||||||
| const existingLayout = layouts.get(layoutId); | ||||||||||||||||
| if (existingLayout) { | ||||||||||||||||
| assertRouteManifestRootBoundary("layout", route, layoutId, existingLayout.rootBoundaryId); | ||||||||||||||||
| } | ||||||||||||||||
| layouts.set(layoutId, { | ||||||||||||||||
| id: layoutId, | ||||||||||||||||
| treePath, | ||||||||||||||||
| rootBoundaryId: route.ids.rootBoundary, | ||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Shared intermediate layouts get last-writer-wins When two routes share a layout (e.g. In practice today this is likely fine because layouts within the same tree path share the same root boundary. But it's an implicit invariant the code doesn't assert. Consider either:
This would make the invariant explicit and catch future regressions if the route graph shape changes. |
||||||||||||||||
| }); | ||||||||||||||||
|
|
||||||||||||||||
| if (index === 0 && route.ids.rootBoundary) { | ||||||||||||||||
| rootBoundaries.set(route.ids.rootBoundary, { | ||||||||||||||||
| id: route.ids.rootBoundary, | ||||||||||||||||
| layoutId, | ||||||||||||||||
| treePath, | ||||||||||||||||
| }); | ||||||||||||||||
| } | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| for (const [index, templateId] of route.ids.templates.entries()) { | ||||||||||||||||
| const treePosition = route.templateTreePositions?.[index]; | ||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: The type system enforces this correctly so it's not a bug, just noting the visual inconsistency. |
||||||||||||||||
| assertRouteManifestTreePosition("template", route, templateId, treePosition); | ||||||||||||||||
|
|
||||||||||||||||
| const existingTemplate = templates.get(templateId); | ||||||||||||||||
| if (existingTemplate) { | ||||||||||||||||
| assertRouteManifestRootBoundary( | ||||||||||||||||
| "template", | ||||||||||||||||
| route, | ||||||||||||||||
| templateId, | ||||||||||||||||
| existingTemplate.rootBoundaryId, | ||||||||||||||||
| ); | ||||||||||||||||
| } | ||||||||||||||||
| templates.set(templateId, { | ||||||||||||||||
| id: templateId, | ||||||||||||||||
| treePath: createAppRouteGraphTreePath(route.routeSegments, treePosition), | ||||||||||||||||
| rootBoundaryId: route.ids.rootBoundary, | ||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same last-writer-wins observation applies to templates -- if two routes share a template id but somehow have different |
||||||||||||||||
| }); | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| // Slots are boundary-agnostic in this minimal read model; unlike layouts | ||||||||||||||||
| // and templates, they do not carry rootBoundaryId facts to guard. | ||||||||||||||||
| for (const slot of route.parallelSlots) { | ||||||||||||||||
| slots.set(slot.id, { | ||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Slots don't get the same This is safe because
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in the latest push. The slot loop now documents the asymmetry: slots are boundary-agnostic in this minimal manifest, while layouts/templates carry |
||||||||||||||||
| id: slot.id, | ||||||||||||||||
| key: slot.key, | ||||||||||||||||
| name: slot.name, | ||||||||||||||||
| }); | ||||||||||||||||
| } | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| return { | ||||||||||||||||
| routes: routeEntries, | ||||||||||||||||
| pages, | ||||||||||||||||
| routeHandlers, | ||||||||||||||||
| layouts, | ||||||||||||||||
| templates, | ||||||||||||||||
| slots, | ||||||||||||||||
| rootBoundaries, | ||||||||||||||||
| }; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| function assertRouteManifestTreePosition( | ||||||||||||||||
| kind: "layout" | "template", | ||||||||||||||||
| route: AppRouteGraphRoute, | ||||||||||||||||
| id: string, | ||||||||||||||||
| treePosition: number | undefined, | ||||||||||||||||
| ): asserts treePosition is number { | ||||||||||||||||
| if (treePosition !== undefined) return; | ||||||||||||||||
|
|
||||||||||||||||
| throw new Error( | ||||||||||||||||
| `[vinext] App route graph invariant violated: missing ${kind} tree position for ${id} on ${route.pattern}`, | ||||||||||||||||
| ); | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| function assertRouteManifestRootBoundary( | ||||||||||||||||
| kind: "layout" | "template", | ||||||||||||||||
| route: AppRouteGraphRoute, | ||||||||||||||||
| id: string, | ||||||||||||||||
| existingRootBoundaryId: RootBoundaryId | null, | ||||||||||||||||
| ): void { | ||||||||||||||||
| if (existingRootBoundaryId === route.ids.rootBoundary) return; | ||||||||||||||||
|
|
||||||||||||||||
| throw new Error( | ||||||||||||||||
| `[vinext] App route graph invariant violated: ${kind} ${id} is shared across root boundaries (${existingRootBoundaryId ?? "none"} and ${route.ids.rootBoundary ?? "none"}) on ${route.pattern}`, | ||||||||||||||||
| ); | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| function createRouteManifestGraphVersion(segmentGraph: StaticSegmentGraph): GraphVersion { | ||||||||||||||||
| // The manifest hash is canonical only if top-level map keys are sorted and | ||||||||||||||||
| // inner route arrays keep their own semantic order: layoutIds/templateIds in | ||||||||||||||||
| // tree-position order, and slotIds in compareStableStrings order. | ||||||||||||||||
| const stableShape = { | ||||||||||||||||
| routes: sortedMapValues(segmentGraph.routes), | ||||||||||||||||
| pages: sortedMapValues(segmentGraph.pages), | ||||||||||||||||
| routeHandlers: sortedMapValues(segmentGraph.routeHandlers), | ||||||||||||||||
| layouts: sortedMapValues(segmentGraph.layouts), | ||||||||||||||||
| templates: sortedMapValues(segmentGraph.templates), | ||||||||||||||||
| slots: sortedMapValues(segmentGraph.slots), | ||||||||||||||||
| rootBoundaries: sortedMapValues(segmentGraph.rootBoundaries), | ||||||||||||||||
| }; | ||||||||||||||||
| return `graph:${createHash("sha256").update(JSON.stringify(stableShape)).digest("hex")}`; | ||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Not blocking — just noting that the prefix is a minor commitment to a format that tests now assert on. If the version is meant to be opaque, consider whether the test should assert |
||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| export async function buildAppRouteGraph( | ||||||||||||||||
| appDir: string, | ||||||||||||||||
| matcher: ValidFileMatcher, | ||||||||||||||||
| ): Promise<{ routes: AppRouteGraphRoute[] }> { | ||||||||||||||||
| ): Promise<{ routes: AppRouteGraphRoute[]; routeManifest: RouteManifest }> { | ||||||||||||||||
| // Find all page.tsx and route.ts files, excluding @slot directories | ||||||||||||||||
| // (slot pages are not standalone routes — they're rendered as props of their parent layout) | ||||||||||||||||
| // and _private folders (Next.js convention for colocated non-route files). | ||||||||||||||||
|
|
@@ -262,7 +510,7 @@ export async function buildAppRouteGraph( | |||||||||||||||
| // Sort: static routes first, then dynamic, then catch-all | ||||||||||||||||
| routes.sort(compareRoutes); | ||||||||||||||||
|
|
||||||||||||||||
| return { routes }; | ||||||||||||||||
| return { routes, routeManifest: createRouteManifest(routes) }; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| function hasParallelSlotDirectory(dir: string): boolean { | ||||||||||||||||
|
|
@@ -705,6 +953,20 @@ export function computeRootParamNames( | |||||||||||||||
| return names; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| function resolveRootBoundaryId( | ||||||||||||||||
| routeSegments: readonly string[], | ||||||||||||||||
| layoutTreePositions: readonly number[], | ||||||||||||||||
| ): RootBoundaryId | null { | ||||||||||||||||
| const rootLayoutPosition = layoutTreePositions[0]; | ||||||||||||||||
| if (rootLayoutPosition === undefined) return null; | ||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Is position 0 a valid root layout position (root layout in the app directory itself)? Looking at
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor asymmetry with Both are correct: A one-line comment here explaining why position 0 is valid (unlike in
Suggested change
|
||||||||||||||||
|
|
||||||||||||||||
| // Position 0 is the app root layout and still owns a real root boundary. | ||||||||||||||||
| // Only a missing layout position means the route is layoutless. | ||||||||||||||||
| return createAppRouteGraphRootBoundaryId( | ||||||||||||||||
| createAppRouteGraphTreePath(routeSegments, rootLayoutPosition), | ||||||||||||||||
| ); | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| function createAppRouteSemanticIds(input: { | ||||||||||||||||
| pattern: string; | ||||||||||||||||
| pagePath: string | null; | ||||||||||||||||
|
|
@@ -723,6 +985,7 @@ function createAppRouteSemanticIds(input: { | |||||||||||||||
| route: createAppRouteGraphRouteId(input.pattern), | ||||||||||||||||
| page: input.pagePath ? createAppRouteGraphPageId(input.pattern) : null, | ||||||||||||||||
| routeHandler: input.routePath ? createAppRouteGraphRouteHandlerId(input.pattern) : null, | ||||||||||||||||
| rootBoundary: resolveRootBoundaryId(input.routeSegments, input.layoutTreePositions), | ||||||||||||||||
| layouts: input.layoutTreePositions.map((treePosition) => | ||||||||||||||||
| createAppRouteGraphLayoutId(createAppRouteGraphTreePath(input.routeSegments, treePosition)), | ||||||||||||||||
| ), | ||||||||||||||||
|
|
||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -15,39 +15,65 @@ | |||||||||||||
| */ | ||||||||||||||
| import { createValidFileMatcher, type ValidFileMatcher } from "./file-matcher.js"; | ||||||||||||||
| import { createRouteTrieCache, matchRouteWithTrie } from "./route-matching.js"; | ||||||||||||||
| import { buildAppRouteGraph, type AppRoute, type AppRouteGraphRoute } from "./app-route-graph.js"; | ||||||||||||||
| import { | ||||||||||||||
| buildAppRouteGraph, | ||||||||||||||
| type AppRoute, | ||||||||||||||
| type AppRouteGraphRoute, | ||||||||||||||
| type RouteManifest, | ||||||||||||||
| } from "./app-route-graph.js"; | ||||||||||||||
| export type { AppRoute } from "./app-route-graph.js"; | ||||||||||||||
| export { computeRootParamNames } from "./app-route-graph.js"; | ||||||||||||||
|
|
||||||||||||||
| type AppRouteGraph = { | ||||||||||||||
| routes: AppRouteGraphRoute[]; | ||||||||||||||
| routeManifest: RouteManifest; | ||||||||||||||
| }; | ||||||||||||||
|
|
||||||||||||||
| // Cache for app routes | ||||||||||||||
| let cachedRoutes: AppRouteGraphRoute[] | null = null; | ||||||||||||||
| let cachedGraph: AppRouteGraph | null = null; | ||||||||||||||
| let cachedAppDir: string | null = null; | ||||||||||||||
| let cachedPageExtensionsKey: string | null = null; | ||||||||||||||
|
|
||||||||||||||
| export function invalidateAppRouteCache(): void { | ||||||||||||||
| cachedRoutes = null; | ||||||||||||||
| cachedGraph = null; | ||||||||||||||
| cachedAppDir = null; | ||||||||||||||
| cachedPageExtensionsKey = null; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| /** | ||||||||||||||
| * Scan the app/ directory and return a list of routes. | ||||||||||||||
| * Scan the app/ directory and return the route graph. | ||||||||||||||
| * TODO(#726): Layer 4 should consume this read model directly once the | ||||||||||||||
| * navigation planner owns route graph facts. | ||||||||||||||
| * | ||||||||||||||
| * @internal | ||||||||||||||
| */ | ||||||||||||||
| export async function appRouter( | ||||||||||||||
| export async function appRouteGraph( | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This is fine if it's intentional scaffolding for Layer 4. But since this is a public API surface (any downstream code can import it), I'd add a brief doc comment noting its purpose:
Suggested change
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in the latest push.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The If the package's |
||||||||||||||
| appDir: string, | ||||||||||||||
| pageExtensions?: readonly string[], | ||||||||||||||
| matcher?: ValidFileMatcher, | ||||||||||||||
| ): Promise<AppRouteGraphRoute[]> { | ||||||||||||||
| ): Promise<AppRouteGraph> { | ||||||||||||||
| matcher ??= createValidFileMatcher(pageExtensions); | ||||||||||||||
| const pageExtensionsKey = JSON.stringify(matcher.extensions); | ||||||||||||||
| if (cachedRoutes && cachedAppDir === appDir && cachedPageExtensionsKey === pageExtensionsKey) { | ||||||||||||||
| return cachedRoutes; | ||||||||||||||
| if (cachedGraph && cachedAppDir === appDir && cachedPageExtensionsKey === pageExtensionsKey) { | ||||||||||||||
| return cachedGraph; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| const graph = await buildAppRouteGraph(appDir, matcher); | ||||||||||||||
| cachedRoutes = graph.routes; | ||||||||||||||
| cachedGraph = graph; | ||||||||||||||
| cachedAppDir = appDir; | ||||||||||||||
| cachedPageExtensionsKey = pageExtensionsKey; | ||||||||||||||
| return graph; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| /** | ||||||||||||||
| * Scan the app/ directory and return a list of routes. | ||||||||||||||
| */ | ||||||||||||||
| export async function appRouter( | ||||||||||||||
| appDir: string, | ||||||||||||||
| pageExtensions?: readonly string[], | ||||||||||||||
| matcher?: ValidFileMatcher, | ||||||||||||||
| ): Promise<AppRouteGraphRoute[]> { | ||||||||||||||
| const graph = await appRouteGraph(appDir, pageExtensions, matcher); | ||||||||||||||
| return graph.routes; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
slot.idhere is typed asstring | undefinedon the baseParallelSlottype (theidfield isid?: string), but onAppRouteGraphParallelSlotit's required (id: string). Theroute.parallelSlotsarray is typed asAppRouteGraphParallelSlot[]so this is safe — but worth noting that if a code path ever passes a bareParallelSlotwith a missingid, the.sort()would silently sort"undefined"strings.The type system prevents this today. Just flagging the narrowing dependency.