Skip to content

feat(router): expose route graph manifest read model#1089

Merged
james-elicx merged 2 commits intocloudflare:mainfrom
NathanDrake2406:nathan/726-graph-02-route-manifest
May 6, 2026
Merged

feat(router): expose route graph manifest read model#1089
james-elicx merged 2 commits intocloudflare:mainfrom
NathanDrake2406:nathan/726-graph-02-route-manifest

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

@NathanDrake2406 NathanDrake2406 commented May 6, 2026

What this changes

Implements #726-GRAPH-02/03 for issue #726 by exposing root-boundary identity from the App Router route graph and adding a minimal RouteManifest read model.

The route graph now includes:

  • RootBoundaryId on graph-minted semantic route IDs
  • deterministic graphVersion
  • RouteManifest with a StaticSegmentGraph
  • semantic lookup maps for routes, pages, route handlers, layouts, templates, slots, and root boundaries

Bonk: please read issue #726 before reviewing so the big-picture route manifest and navigation planner architecture is fresh.

Why

Issue #726 is moving route meaning out of transport keys and scattered runtime observations. The graph already minted semantic IDs for route entries, but callers still only received the raw routes array, with root-boundary identity implicit in layoutTreePositions[0].

That makes future planner, compatibility, and cache-proof work depend on incidental route array shape rather than an owned graph read model.

Approach

This keeps the current route matching behavior intact and adds the thinnest read model on top of the existing route graph output.

The manifest is derived after route validation and sorting, uses only stable semantic facts for its version input, and fails fast if generated layout/template IDs ever lose their matching tree-position metadata. It deliberately does not add a transition automaton, planner ownership, cache dependency graph, skip transport, or runtime compatibility semantics.

appRouteGraph() is exposed as the Layer 4 handoff point for the future #726 navigation planner while appRouter() remains backward-compatible for current callers.

Correctness oracle: Vinext internal invariant for issue #726. The read model must expose stable graph facts without changing current App Router routing behavior.

Rebase note

This branch also includes a one-line tests/app-elements.test.ts expectation update after rebasing onto current main: upstream added artifactCompatibility to AppElementsWire.readMetadata(), and the PR's Vitest unit CI failed until the legacy metadata assertion matched the current codec read model. This is test-only and does not change runtime behavior.

Validation

  • vp test run tests/app-elements.test.ts tests/routing.test.ts tests/app-route-graph.test.ts tests/entry-templates.test.ts
  • vp check packages/vinext/src/routing/app-router.ts packages/vinext/src/routing/app-route-graph.ts tests/app-elements.test.ts
  • commit hook: vp check --fix, knip --no-progress, vp pack
  • vp run vinext#build

vp run vinext#build and the commit hook pack still emit the existing virtual-module external warnings for generated entry placeholders.

Risks / follow-ups

This PR intentionally does not promote any planner decisions or cache authority. It only exposes the minimal graph read model needed by later #726 work.

Copilot AI review requested due to automatic review settings May 6, 2026 04:11
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 6, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@1089

commit: 38ce9af

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@NathanDrake2406 NathanDrake2406 force-pushed the nathan/726-graph-02-route-manifest branch from 9311816 to fa014f3 Compare May 6, 2026 04:50
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: feat(router): expose route graph manifest read model

Good PR overall -- this is a clean, minimal read model that lines up with what issue #726 Layer 3 asks for. The manifest is derived after validation/sorting, uses only stable semantic facts, and the test coverage is solid (determinism, locale insensitivity, root-boundary multiplicity, layoutless routes). A few observations below.

What looks good

  • compareStableStrings for deterministic map ordering instead of localeCompare -- correct and tested.
  • assertRouteManifestTreePosition as a fail-fast invariant rather than silent fallback.
  • Defensive copies ([...route.patternParts], etc.) so the manifest is decoupled from mutable route objects.
  • The routeManifest return is backward-compatible -- the only production caller (app-router.ts:47) destructures graph.routes.
  • No behavior changes to existing routing logic.

Issues and suggestions

layouts.set(layoutId, {
id: layoutId,
treePath,
rootBoundaryId: route.ids.rootBoundary,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shared intermediate layouts get last-writer-wins rootBoundaryId.

When two routes share a layout (e.g. layout:/dashboard used by both /dashboard/a under root boundary A and /dashboard/b under root boundary B, if such a topology were possible through route groups), the second route to iterate will silently overwrite the first's rootBoundaryId on the shared layout entry.

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:

  • Adding an assertion that if layouts.has(layoutId), the existing rootBoundaryId matches the new one, or
  • Adding a comment explaining why last-writer-wins is safe here.

This would make the invariant explicit and catch future regressions if the route graph shape changes.

slots: sortedMapValues(segmentGraph.slots),
rootBoundaries: sortedMapValues(segmentGraph.rootBoundaries),
};
return `graph:${fnv1a64(JSON.stringify(stableShape))}`;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JSON.stringify ordering depends on insertion order of object literal keys.

The stableShape object literal has string keys (routes, pages, routeHandlers, etc.), which JSON.stringify serializes in insertion order per spec. This is deterministic because the literal order is fixed in source. However, the values within each array come from sortedMapValues which sorts by map key -- good.

The subtle risk is that the RouteManifestRoute (and other) objects contain arrays like layoutIds, templateIds, slotIds whose ordering comes from the route iteration order (layouts/templates) or explicit sort (slotIds). If a future change affects route iteration order inside createStaticSegmentGraph, it could change the JSON output and therefore the graphVersion even when the semantic graph hasn't changed.

This is acceptable for now since the routes are already sorted before manifest creation, but worth a brief comment noting the stability contract: "Route iteration order is stable because routes is sorted by compareRoutes before manifest creation."

templates.set(templateId, {
id: templateId,
treePath: createAppRouteGraphTreePath(route.routeSegments, treePosition),
rootBoundaryId: route.ids.rootBoundary,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 rootBoundaryId, the second route silently wins. Same mitigation applies as for layouts.

if (typeof originalLocaleCompare !== "function") {
throw new Error("Expected String.prototype.localeCompare to be a function");
}
Object.defineProperty(String.prototype, "localeCompare", {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Monkey-patching String.prototype.localeCompare is creative but fragile.

This works because compareStableStrings uses </> operators (not localeCompare), so the test proves the graph version is locale-insensitive. But if any other code path called during buildAppRouteGraph relies on localeCompare (e.g. Array.prototype.sort with a default comparator in some dependency), this monkey-patch could cause unexpected side effects.

The test is still valuable as-is -- just noting the fragility. A comment explaining what the monkey-patch is proving ("verifies createRouteManifestGraphVersion does not use locale-sensitive comparison") would help future readers.

});
});

it("exposes a minimal RouteManifest read model keyed by semantic ids", async () => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The manifest test covers routes, pages, layouts, templates, slots, and root boundaries -- but does not exercise the routeHandlers map. Consider adding a fixture with a route.ts and asserting segmentGraph.routeHandlers is populated. The createSemanticIdsFixture only has pages, so routeHandlers is always empty in this test.

parallelSlots: AppRouteGraphParallelSlot[];
};

export type GraphVersion = string;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: GraphVersion and RootBoundaryId are both string type aliases. That's fine for now as the PR description says this is the thinnest read model. But as the #726 work continues, consider branded/opaque types (e.g. string & { __brand: 'GraphVersion' }) to prevent accidental interchange. Not blocking -- just a note for future layers.

route: "route:/dashboard/:id",
page: "page:/dashboard/:id",
routeHandler: "route-handler:/dashboard/:id",
rootBoundary: "root-boundary:/",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good -- adding rootBoundary to the existing fixture ensures the entry-template tests stay in sync with the new AppRouteSemanticIds shape.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 6, 2026

Review posted. Here's a summary of the feedback:

Overall: Clean, minimal read model that matches #726 Layer 3 scope. No behavior changes to existing routing, backward-compatible return type, good test coverage.

Key observations:

  1. Shared layout last-writer-wins (app-route-graph.ts:343,364): When multiple routes share a layout or template id, the rootBoundaryId is silently overwritten. This is safe today because shared layouts share the same root boundary, but the invariant should be asserted or documented to prevent future regressions.

  2. graphVersion stability contract (app-route-graph.ts:411): JSON.stringify determinism depends on route iteration order being stable (which it is, since routes are sorted before manifest creation). Worth a brief comment documenting this.

  3. localeCompare monkey-patch fragility (tests/app-route-graph.test.ts:59): The test is valuable but could cause unexpected side effects if internal code paths use localeCompare. A clarifying comment would help.

  4. Missing routeHandlers test coverage (tests/app-route-graph.test.ts:291): The manifest test fixture only has pages, so routeHandlers is always empty. Consider adding a fixture with a route.ts.

  5. Type aliases as plain strings (app-route-graph.ts:174): GraphVersion and RootBoundaryId are string aliases -- fine for now, but branded types would prevent accidental interchange as App Router layout persistence: Route Manifest and Navigation Planner architecture #726 work continues.

github run

@NathanDrake2406 NathanDrake2406 force-pushed the nathan/726-graph-02-route-manifest branch from fa014f3 to b2f8ab2 Compare May 6, 2026 05:32
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: feat(router): expose route graph manifest read model

Good work addressing the first bonk review — the assertRouteManifestRootBoundary invariant check is a solid improvement over the original last-writer-wins behavior. The core design (thin read model derived after validation/sort, fail-fast assertions, defensive copies, backward-compatible return type) is clean and well-scoped for Layer 3.

A few remaining items, one of which is a real bug in the test helper.

pages: Array.from(manifest.segmentGraph.pages.entries()),
templates: Array.from(manifest.segmentGraph.templates.entries()),
slots: Array.from(manifest.segmentGraph.slots.entries()),
rootBoundaries: Array.from(manifest.segmentGraph.rootBoundaries.entries()),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

snapshotRouteManifest omits routeHandlers from the snapshot, which means the "keeps RouteManifest graph output stable across different filesystem roots" test at line 418 does not cover route handler stability at all. This is a quiet gap — if route handler entries were non-deterministic across filesystem roots, this test wouldn't catch it.

Suggested change
rootBoundaries: Array.from(manifest.segmentGraph.rootBoundaries.entries()),
rootBoundaries: Array.from(manifest.segmentGraph.rootBoundaries.entries()),
routeHandlers: Array.from(manifest.segmentGraph.routeHandlers.entries()),
};

Comment thread tests/app-route-graph.test.ts Outdated
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const rootBoundaryIds = graph.routes
.map((route) => route.ids.rootBoundary)
.sort((left, right) => String(left).localeCompare(String(right)));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this sorts with localeCompare, but the manifest code itself deliberately avoids locale-sensitive sorting via compareStableStrings. Using localeCompare in the test assertion could mask a locale-dependent ordering difference in theory (though in practice these strings are ASCII). Consider using .sort() (default comparator, which is codepoint-based) for consistency with the production code's philosophy:

Suggested change
.sort((left, right) => String(left).localeCompare(String(right)));
.sort((left, right) => (String(left) < String(right) ? -1 : String(left) > String(right) ? 1 : 0));

Not blocking, just a consistency note.

slots: sortedMapValues(segmentGraph.slots),
rootBoundaries: sortedMapValues(segmentGraph.rootBoundaries),
};
return `graph:${fnv1a64(JSON.stringify(stableShape))}`;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The graphVersion comment at line 428-429 is helpful. One additional subtlety worth documenting: JSON.stringify on the stableShape object literal serializes top-level keys in source-code insertion order (per spec), and nested arrays within each RouteManifestRoute (like layoutIds, templateIds, slotIds) derive their order from route iteration. The slotIds are explicitly sorted by compareStableStrings (line 314), which is good. But layoutIds and templateIds inherit their order from route.ids.layouts / route.ids.templates, which come from layoutTreePositions.map(...) — meaning their order is governed by tree position depth, not string comparison.

This is actually fine and semantically correct (tree order is stable), but the comment could be extended slightly to note that inner arrays use tree-position order, not alphabetical order, so readers don't expect sortedMapValues to cover that.

layoutTreePositions: readonly number[],
): RootBoundaryId | null {
const rootLayoutPosition = layoutTreePositions[0];
if (rootLayoutPosition === undefined) return null;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The resolveRootBoundaryId function returns null when layoutTreePositions[0] is undefined, which handles layoutless routes correctly. However, computeRootParamNames at line 944 also guards on layoutTreePositions[0] but uses rootLayoutPosition == null || rootLayoutPosition <= 0 (loose equality, catching both null and undefined, plus a <= 0 guard). The difference in guard styles between these two sibling functions is slightly surprising — resolveRootBoundaryId doesn't guard against rootLayoutPosition <= 0.

Is position 0 a valid root layout position (root layout in the app directory itself)? Looking at computeLayoutTreePositions, position 0 means the layout is at appDir itself — the true root. So computeRootParamNames returns [] for position 0 (no segments before the root), which is correct. resolveRootBoundaryId would create root-boundary:/ for position 0, which is also correct. The asymmetry is fine on closer reading, but a brief comment on why <= 0 isn't needed here would help future readers.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 6, 2026

Review posted. Here's a summary of the feedback:

Overall: Clean, well-scoped read model for #726 Layer 3. The assertRouteManifestRootBoundary invariant check properly addresses the last-writer-wins concern from the first review. Design choices (thin read model, fail-fast assertions, defensive copies, backward-compatible return type) are solid.

Key findings:

  1. snapshotRouteManifest omits routeHandlers (tests/app-route-graph.test.ts:50): The stability-across-filesystem-roots test doesn't cover route handler entries because the snapshot helper forgot to include them. Suggested fix with a code suggestion.

  2. localeCompare used in test assertion (tests/app-route-graph.test.ts:374): The distinct-root-boundaries test sorts with localeCompare, which is inconsistent with the production code's deliberate use of codepoint-based comparison. Minor consistency nit.

  3. graphVersion stability comment could be more precise (app-route-graph.ts:439): The existing comment documents the sorted-route-iteration contract, but inner arrays (layoutIds, templateIds) use tree-position order, not string comparison. Worth noting so readers don't assume sortedMapValues covers everything.

  4. Asymmetric guard style between resolveRootBoundaryId and computeRootParamNames (app-route-graph.ts:959): Both check layoutTreePositions[0], but the older function has a <= 0 guard that the new one omits. Both are correct, but the asymmetry could confuse future readers.

github run

@NathanDrake2406 NathanDrake2406 force-pushed the nathan/726-graph-02-route-manifest branch from b2f8ab2 to 3922aa2 Compare May 6, 2026 06:03
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: feat(router): expose route graph manifest read model

This is a well-scoped Layer 3 deliverable. The read model is thin, derived after validation/sort, uses defensive copies, and the return type change is backward-compatible (the only production caller at app-router.ts:47 destructures graph.routes). The assertRouteManifestRootBoundary invariant check that was added after the first bonk review is a real improvement — it turns a silent last-writer-wins scenario into a fail-fast assertion.

Test coverage is good: determinism, locale insensitivity, root-boundary multiplicity, layoutless routes, cross-filesystem-root stability, and route handler entries in the manifest test. The two prior bonk reviews covered most of the surface area well.

One issue that should be fixed before merge, plus a few smaller items.

Must fix

  1. routeManifest is not cached in the production callerapp-router.ts:47-51 calls buildAppRouteGraph and caches graph.routes, but discards graph.routeManifest. When a later #726 layer needs the manifest at runtime (Layer 4 NavigationPlanner needs it as input), the caller will need to be updated. This isn't a bug today, but the manifest is computed on every uncached call and immediately thrown away. Either cache it alongside cachedRoutes now, or add a comment noting it's intentionally discarded until a consumer exists. I'd prefer caching it since it's cheap and avoids a future "oops we recompute it" moment.

Should fix

  1. snapshotRouteManifest is missing the routeHandlers key — Wait, looking again: it does include routeHandlers at line 48. The first bonk review's suggestion to add it appears to have been addressed. Good.

Nits / non-blocking

slots: sortedMapValues(segmentGraph.slots),
rootBoundaries: sortedMapValues(segmentGraph.rootBoundaries),
};
return `graph:${fnv1a64(JSON.stringify(stableShape))}`;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment at lines 428-431 is good but could be slightly more precise about what "semantic order" means for each inner array.

layoutIds and templateIds come from route.ids.layouts / route.ids.templates, which are mapped from layoutTreePositions / templateTreePositions — so their order is tree-position depth, not alphabetical. slotIds are explicitly sorted by compareStableStrings (line 314). The sortedMapValues call covers top-level map iteration order but not these inner arrays.

This is all correct and stable, but a reader skimming the comment might assume sortedMapValues handles everything. Consider extending the comment to note that inner arrays use tree-position order (layouts/templates) or explicit stable-string sort (slots).

layoutTreePositions: readonly number[],
): RootBoundaryId | null {
const rootLayoutPosition = layoutTreePositions[0];
if (rootLayoutPosition === undefined) return null;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor asymmetry with computeRootParamNames at line 946, which guards rootLayoutPosition == null || rootLayoutPosition <= 0. Here you only guard === undefined.

Both are correct: computeRootParamNames returns [] for position 0 because there are no segments before the root layout, while resolveRootBoundaryId correctly returns a root-boundary:/ for position 0 (the root layout at the app directory itself). The <= 0 guard in computeRootParamNames is specifically about "no segments to slice", not about validity.

A one-line comment here explaining why position 0 is valid (unlike in computeRootParamNames) would help readers who notice the asymmetry:

Suggested change
if (rootLayoutPosition === undefined) return null;
const rootLayoutPosition = layoutTreePositions[0];
if (rootLayoutPosition === undefined) return null;
// Position 0 means the root layout is at appDir itself — it still owns a
// real root boundary. (computeRootParamNames treats 0 differently because
// there are no segments before the root layout to extract param names from.)

parallelSlots: AppRouteGraphParallelSlot[];
};

export type GraphVersion = string;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit for future work: GraphVersion and RootBoundaryId as plain string aliases provide documentation value but no type safety. As #726 layers accumulate more ID types (OperationToken, RouteSnapshotId, DeploymentVersion, etc.), branded types would prevent accidental interchange:

export type GraphVersion = string & { readonly __brand: 'GraphVersion' };
export type RootBoundaryId = string & { readonly __brand: 'RootBoundaryId' };

Not blocking — the issue description says "thinnest read model" and plain aliases are fine for now. Just flagging for when the type surface grows.

const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const rootBoundaryIds = graph.routes
.map((route) => route.ids.rootBoundary)
.sort((left, right) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sort comparator manually implements codepoint-based comparison, which is consistent with compareStableStrings in production code. Good.

Tiny nit: you could extract this into a small helper since the same pattern appears at line 375 and also conceptually in the withReverseLocaleCompare test. Not worth a change, just noting the duplication.

}
// This proves RouteManifest graphVersion canonicalization does not depend on
// locale-sensitive sorting. Keep the patched window scoped to graph building.
Object.defineProperty(String.prototype, "localeCompare", {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The localeCompare monkey-patch is a creative way to prove locale insensitivity. The scoping comment at line 60-61 is helpful.

One fragility concern: if any code path during buildAppRouteGraph (including scanWithExtensions, fs.readdir sort order, etc.) uses localeCompare internally, this patch would affect it in unexpected ways. In practice this is fine because Node.js filesystem APIs don't use localeCompare, and the production sorting code uses compareRoutes / compareStableStrings which use </> operators.

The test is valuable as-is. Just noting the implicit assumption for future maintainers.

});

expect(rootBoundaryIds).toEqual(["root-boundary:/(marketing)", "root-boundary:/(shop)"]);
expect(Array.from(graph.routeManifest.segmentGraph.rootBoundaries.keys()).sort()).toEqual([
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.sort() with no comparator uses codepoint-based comparison by default, which matches the production compareStableStrings behavior. This is fine here since the values are simple ASCII strings. Just noting that this is a different sort invocation style than lines 375-381 (explicit comparator) — both are correct but inconsistent in style.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 6, 2026

Review posted. Here's a summary:

Overall: Clean, well-scoped Layer 3 deliverable for #726. The read model is thin, derived after validation/sort, backward-compatible (only production caller at app-router.ts:47 destructures graph.routes), and the assertRouteManifestRootBoundary invariant is a solid fail-fast guard.

Key findings:

  1. Must fix — routeManifest not cached in production caller: app-router.ts:47-51 caches graph.routes but discards graph.routeManifest. The manifest is computed on every uncached call and thrown away. Should either cache it now or add a comment noting it's intentionally discarded until a consumer exists.

  2. Nit — graphVersion comment precision: The comment about stability could note that inner arrays (layoutIds, templateIds) use tree-position order, not alphabetical, while slotIds use explicit stable-string sort. sortedMapValues only covers top-level map iteration.

  3. Nit — asymmetric guard style: resolveRootBoundaryId uses === undefined while sibling computeRootParamNames uses == null || <= 0. Both are correct but a brief comment explaining why position 0 is valid here would help readers.

  4. Nit — branded types: GraphVersion and RootBoundaryId as plain string aliases are fine for the thinnest read model, but should move to branded types as App Router layout persistence: Route Manifest and Navigation Planner architecture #726 accumulates more ID types.

  5. Nit — localeCompare monkey-patch: Creative and valuable test, with appropriate scoping comment. Just noting the implicit assumption that no code path during buildAppRouteGraph uses localeCompare internally.

github run

@NathanDrake2406 NathanDrake2406 force-pushed the nathan/726-graph-02-route-manifest branch 2 times, most recently from 9ec70f8 to 467ec30 Compare May 6, 2026 07:36
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: feat(router): expose route graph manifest read model

Clean, well-scoped read model. The design is sound: thin derived manifest, fail-fast assertions, defensive copies, backward-compatible return type change. Three prior bonk reviews have already covered the surface well, so I'll focus on what remains unaddressed.

Must fix

  1. Unrelated app-elements.test.ts change included in this PR — see inline comment.

Should fix

  1. appRouteGraph is exported but has zero production callers — The new appRouteGraph() function is exported from app-router.ts but nothing in packages/vinext/src/ imports it. Only the test does. The third bonk review flagged that the manifest isn't cached in the production path — it is cached (the whole graph object is cached), but it's unreachable from production code since index.ts still calls appRouter() which only returns graph.routes. I'd either (a) add a TODO(#726) comment on the export noting it exists for Layer 4, or (b) don't export appRouteGraph at all yet and only export it when a production consumer exists. Exporting unused public API is a maintenance burden.

Nits (non-blocking)

if (!isAppElementsRecord(payload)) return;

expect(AppElementsWire.readMetadata(payload)).toEqual({
artifactCompatibility: createArtifactCompatibilityEnvelope(),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change adds artifactCompatibility to the test expectation, but the test already passes on main without it (the .toEqual() assertion passes because readMetadata returns artifactCompatibility and .toEqual does a deep structural comparison — wait, actually .toEqual is a deep equality check, which means it would fail if the actual result has a key the expected object doesn't have... let me reconsider).

Actually, .toEqual ignores extra properties in the actual object? No — Vitest's .toEqual uses expect's deep.equal which checks both ways. So if readMetadata returns artifactCompatibility and the test expectation didn't include it, the test should have been failing. But I confirmed it passes on main.

Looking more carefully: toEqual in Vitest uses jestExpect's toEqual, which does asymmetric matching — it only checks that the expected structure is present in the actual result. Edit: No, toEqual checks deep strict equality. If this test passes on main without the artifactCompatibility key, then readMetadata must not have been returning it at the time of the branch point.

Regardless, this looks like a legitimate fix to make the test assertion complete. But it's unrelated to the route manifest feature. If it's fixing a pre-existing gap in the test, consider putting it in a separate commit or noting it in the PR description so reviewers understand why it's here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not a route-manifest behavior change. After rebasing onto current main, upstream had added artifactCompatibility to AppElementsWire.readMetadata(), and CI failed in tests/app-elements.test.ts at this legacy metadata assertion. The one-line test update keeps the branch aligned with the current codec read model; no runtime code changed. I added a Rebase note to the PR body so the diff context is explicit.

routeHandlerId: route.ids.routeHandler,
layoutIds: [...route.ids.layouts],
templateIds: [...route.ids.templates],
slotIds: route.parallelSlots.map((slot) => slot.id).sort(compareStableStrings),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

slot.id here is typed as string | undefined on the base ParallelSlot type (the id field is id?: string), but on AppRouteGraphParallelSlot it's required (id: string). The route.parallelSlots array is typed as AppRouteGraphParallelSlot[] so this is safe — but worth noting that if a code path ever passes a bare ParallelSlot with a missing id, the .sort() would silently sort "undefined" strings.

The type system prevents this today. Just flagging the narrowing dependency.

slots: sortedMapValues(segmentGraph.slots),
rootBoundaries: sortedMapValues(segmentGraph.rootBoundaries),
};
return `graph:${fnv1a64(JSON.stringify(stableShape))}`;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: JSON.stringify on the stableShape serializes top-level object keys in source-literal order (per ES spec), and the values come from sortedMapValues which sorts by map key — good.

But the RouteManifestRoute objects inside the routes array contain inner arrays (layoutIds, templateIds) whose order is tree-position order (not alphabetical), and slotIds is explicitly sorted by compareStableStrings. Both are stable, but for different reasons. The comment at lines 428-431 is helpful but doesn't distinguish these two stability guarantees. A future maintainer changing template tree-position ordering might not realize it affects graphVersion. Consider extending the comment to note the inner-array ordering sources.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tightened in the latest push. The comment now names the two inner-array ordering sources directly: tree-position order for layouts/templates and stable string order for slots.

* Scan the app/ directory and return the route graph.
*/
export async function appRouter(
export async function appRouteGraph(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

appRouteGraph is exported but has no production callers — only the test in routing.test.ts imports it. All production code still goes through appRouter(), which discards the manifest.

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
export async function appRouteGraph(
/**
* Scan the app/ directory and return the route graph.
* TODO(#726): Currently unused in production exposed for Layer 4 NavigationPlanner.
*/
export async function appRouteGraph(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the latest push. appRouteGraph() now has a TODO(#726) doc comment making the Layer 4 planner handoff explicit while preserving appRouter() for current callers.

}

for (const slot of route.parallelSlots) {
slots.set(slot.id, {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Slots don't get the same assertRouteManifestRootBoundary dedup check that layouts and templates get. A slot can appear on multiple routes (inherited parallel slots), but the set here silently last-writer-wins on the slot entry.

This is safe because RouteManifestSlot doesn't carry rootBoundaryId — it only has id, key, name, all of which are the same regardless of which route contributes the slot. But it's worth noting the asymmetry: layouts/templates get an assertion guard because they carry rootBoundaryId, while slots don't need one because they're boundary-agnostic. A brief comment would help readers understand why slots are treated differently.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 rootBoundaryId facts and need the invariant guard.

parallelSlots: AppRouteGraphParallelSlot[];
};

export type GraphVersion = string;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All three bonk reviews have flagged this, so I'll just +1: plain string aliases are fine for the thinnest read model, but as #726 layers accumulate more ID types, branded types would prevent accidental interchange. Not blocking.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 6, 2026

Review posted. Here's a summary of the feedback:

Overall: Clean, well-scoped Layer 3 read model. Design is sound -- thin derived manifest, fail-fast assertions with assertRouteManifestRootBoundary, defensive copies, backward-compatible return type. The three prior bonk reviews covered most of the surface well.

Key findings:

  1. Must fix -- unrelated app-elements.test.ts change: The PR includes a one-line change to app-elements.test.ts adding artifactCompatibility to a test expectation. This appears unrelated to the route manifest feature and should either be in a separate commit or explained in the PR description.

  2. Should fix -- appRouteGraph exported with no production callers: The new appRouteGraph() function is exported from app-router.ts but nothing in packages/vinext/src/ imports it. The manifest is properly cached (the whole graph object is cached), but it's unreachable from production code since index.ts still calls appRouter(). Suggested adding a TODO(#726) comment noting it's scaffolding for Layer 4.

  3. Nit -- slot dedup asymmetry undocumented: Layouts and templates get assertRouteManifestRootBoundary guards but slots silently last-writer-wins. This is safe because slots don't carry rootBoundaryId, but the asymmetry should be commented.

  4. Nit -- graphVersion stability comment precision: Inner arrays use tree-position order (layouts/templates) vs explicit stable-string sort (slots), but the comment doesn't distinguish these stability guarantees.

  5. Nit -- branded types: +1 to all three prior reviews flagging GraphVersion/RootBoundaryId as plain string aliases. Fine for now, should become branded as App Router layout persistence: Route Manifest and Navigation Planner architecture #726 accumulates more ID types.

github run

@NathanDrake2406 NathanDrake2406 force-pushed the nathan/726-graph-02-route-manifest branch from 467ec30 to eb92039 Compare May 6, 2026 07:54
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: feat(router): expose route graph manifest read model

Clean, well-scoped work. The read model is thin and derived, the fail-fast assertions (assertRouteManifestRootBoundary, assertRouteManifestTreePosition) are the right pattern, defensive copies prevent mutation leaks, and the caching refactor in app-router.ts is correct. Test coverage is solid: determinism, locale insensitivity, distinct root boundaries, layoutless routes, cross-filesystem-root stability, and route handler entries.

I have one issue to flag and a few nits. The prior bonk reviews already covered most of the surface -- I'll avoid repeating points that were already addressed.

Issue

fnv1a64 is not a real 64-bit hash — it's two independent 32-bit FNV-1a rounds concatenated. The second round uses a different seed but still processes bytes in exactly the same sequence. This means the two halves are correlated: they don't give you independent collision resistance, so the effective entropy is closer to 32 bits than 64. For a graphVersion used as a cache/identity key in future #726 work, this may matter. Using node:crypto (createHash('sha256') truncated, or even createHash('md5')) would give actual collision resistance with negligible perf difference at these input sizes. Not blocking for this PR since the hash is internal and the manifest is small, but worth noting before #726 layers start depending on graphVersion for cache invalidation correctness.

Nits (non-blocking)

Inline below.

slots: sortedMapValues(segmentGraph.slots),
rootBoundaries: sortedMapValues(segmentGraph.rootBoundaries),
};
return `graph:${fnv1a64(JSON.stringify(stableShape))}`;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: JSON.stringify on an object literal serializes keys in insertion order (per spec), so top-level ordering is fixed by source code. But the readonly arrays inside each RouteManifestRoute (layoutIds, templateIds, slotIds) derive their ordering from different sources:

  • layoutIds/templateIds: tree-position order (from layoutTreePositions.map(...))
  • slotIds: explicit compareStableStrings sort (line 314)

sortedMapValues only handles the top-level map iteration order. If a future change reorders tree positions, graphVersion changes even if the semantic graph is identical. The comment at 430-432 captures the contract well but could name these two distinct stability sources explicitly so a future editor doesn't assume sortedMapValues covers inner arrays.

for (let index = 0; index < route.ids.layouts.length; index++) {
const layoutId = route.ids.layouts[index];
const treePosition = route.layoutTreePositions[index];
if (layoutId === undefined) continue;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tiny robustness nit: the continue here skips a layout whose ID is undefined, but route.ids.layouts is built from layoutTreePositions.map(...) (line 990-992), which always returns a string for each position. So layoutId can never be undefined in practice — the array has no holes. This guard is harmless but might mislead a reader into thinking sparse layout ID arrays are expected. Consider either removing it or adding a comment noting it's defensive.

parallelSlots: AppRouteGraphParallelSlot[];
};

export type GraphVersion = string;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 to prior reviews on branded types. Not blocking, but as a concrete suggestion for when this graduates: Flavor types (lightweight brands that don't require casts at construction) tend to work well for graph IDs:

type Flavor<T, Brand> = T & { readonly __flavor?: Brand };
export type GraphVersion = Flavor<string, 'GraphVersion'>;
export type RootBoundaryId = Flavor<string, 'RootBoundaryId'>;

This prevents accidental interchange (GraphVersion isn't assignable to RootBoundaryId) without requiring explicit casts when creating the values from string literals.

});

expect(rootBoundaryIds).toEqual(["root-boundary:/(marketing)", "root-boundary:/(shop)"]);
expect(Array.from(graph.routeManifest.segmentGraph.rootBoundaries.keys()).sort()).toEqual([
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The default .sort() (no comparator) uses string coercion + codepoint comparison, which matches compareStableStrings for plain strings. But the null values in rootBoundaryIds at line 374 would be coerced to "null", which sorts after "root-boundary:...". Since this test fixture has no null root boundaries, it's fine — but if a future test adds a layoutless route to this fixture, the String(left) explicit coercion at line 376 would be needed. The explicit comparator at lines 375-381 is the safer choice and consistent with the production code's philosophy. Good.

* navigation planner owns route graph facts.
*/
export async function appRouter(
export async function appRouteGraph(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The TODO(#726) comment addresses the prior review feedback well. One remaining observation: appRouteGraph is exported from app-router.ts, but app-router.ts's re-exports in index.ts (line 11) only import appRouter and invalidateAppRouteCache. So appRouteGraph is reachable by deep-importing vinext/routing/app-router, but not from the package's public surface. This is probably intentional (internal API for Layer 4), but worth confirming — if it's meant to stay internal, a @internal JSDoc tag would signal that.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 6, 2026

Review posted. Here's a summary of the feedback:

Overall: Clean, well-scoped Layer 3 read model for #726. The design is sound: thin derived manifest, fail-fast assertions (assertRouteManifestRootBoundary, assertRouteManifestTreePosition), defensive copies, backward-compatible caching refactor, and solid test coverage.

Key findings:

  1. fnv1a64 collision resistance (flagged, not blocking): The hash utility concatenates two correlated 32-bit FNV-1a rounds, giving effective entropy closer to 32 bits than 64. Fine for this PR since the hash is internal, but worth upgrading to node:crypto before App Router layout persistence: Route Manifest and Navigation Planner architecture #726 layers depend on graphVersion for cache invalidation correctness.

  2. graphVersion stability comment precision (nit): Inner arrays use tree-position order (layouts/templates) vs explicit stable-string sort (slots), but the comment doesn't distinguish these two stability sources. A future editor might assume sortedMapValues covers inner arrays.

  3. Defensive undefined guard on layoutId (nit): route.ids.layouts is always a dense array from layoutTreePositions.map(...), so layoutId can never be undefined. The guard is harmless but could mislead readers.

  4. appRouteGraph export visibility (nit): Exported from app-router.ts but not from index.ts, so it's reachable only via deep import. A @internal JSDoc tag would signal this is intentional scaffolding for Layer 4.

  5. Branded types (nit, +1 to prior reviews): Suggested lightweight Flavor types for when the ID type surface grows.

github run

@NathanDrake2406 NathanDrake2406 force-pushed the nathan/726-graph-02-route-manifest branch from eb92039 to 16ca243 Compare May 6, 2026 09:28
@NathanDrake2406
Copy link
Copy Markdown
Contributor Author

Addressed the latest Bonk review in 16ca2434:

  • Switched RouteManifest.graphVersion from the shared fnv1a64 helper to a SHA-256 digest in the Node-only route graph builder. I kept the shared hash utility unchanged because it is used by unrelated runtime/cache paths.
  • Added lightweight Flavor aliases for GraphVersion and RootBoundaryId.
  • Removed the misleading dense-array undefined guards by iterating layout/template ids with entries().
  • Marked appRouteGraph() as @internal.
  • Tightened the graph-version test to require a 64-char hex digest.

Validated with targeted route graph/app-elements tests, touched-file vp check, vp run vinext#build, and the commit hook (vp check --fix, knip, vp pack).

@NathanDrake2406 NathanDrake2406 force-pushed the nathan/726-graph-02-route-manifest branch from 16ca243 to e04749e Compare May 6, 2026 09:31
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: feat(router): expose route graph manifest read model

This is a well-scoped, clean addition. The read model is thin, derived post-validation/sort, uses defensive copies, and the caching refactor in app-router.ts is correct. The five prior bonk reviews have covered this thoroughly and the author has addressed the substantive feedback (SHA-256 for graphVersion, Flavor types, @internal annotation, slot asymmetry comments, assertRouteManifestRootBoundary guards). I have one remaining observation and a few minor items.

What looks good

  • Flavor branded types for GraphVersion and RootBoundaryId — lightweight and appropriate for this stage.
  • SHA-256 via node:crypto for graphVersion — correct collision resistance, follows the project's "prefer Node built-ins" convention.
  • assertRouteManifestRootBoundary as a fail-fast invariant for shared layouts/templates.
  • assertRouteManifestTreePosition to catch generated ID / tree-position metadata drift.
  • appRouter() delegates to appRouteGraph() with shared cache — no duplication, backward-compatible.
  • Test coverage: determinism, locale insensitivity, distinct root boundaries, layoutless routes, cross-filesystem-root stability, route handler entries, caching identity.
  • compareStableStrings using </> operators instead of localeCompare — correct and tested.
  • Slot boundary-agnostic comment is clear.

Observations below.

slots: sortedMapValues(segmentGraph.slots),
rootBoundaries: sortedMapValues(segmentGraph.rootBoundaries),
};
return `graph:${createHash("sha256").update(JSON.stringify(stableShape)).digest("hex")}`;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The graph: prefix means graphVersion values are 71 characters ("graph:" + 64 hex chars). This is fine, but the prefix creates a slight oddity: the Flavor-branded GraphVersion type suggests it's an opaque token, yet the format is publicly observable and tested against (/^graph:[a-f0-9]{64}$/). If Layer 4 ever needs to compare graph versions for ordering (not just equality), the graph: prefix would need to be stripped first.

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 typeof manifest.graphVersion === 'string' && manifest.graphVersion.length > 0 instead of matching the internal format.

}

for (const [index, templateId] of route.ids.templates.entries()) {
const treePosition = route.templateTreePositions?.[index];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: route.templateTreePositions is accessed with optional chaining (?.) here, but route.layoutTreePositions at line 336 is not. Looking at the AppRoute type, templateTreePositions is typed as number[] | undefined (optional), while layoutTreePositions is number[] (required). The optional chaining is correct, but this asymmetry between the two loops is easy to miss — a reader might wonder why one needs ?. and the other doesn't.

The type system enforces this correctly so it's not a bug, just noting the visual inconsistency.

patternParts: [...route.patternParts],
isDynamic: route.isDynamic,
paramNames: [...route.params],
rootParamNames: [...(route.rootParamNames ?? [])],
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: route.rootParamNames is spread with a fallback (?? []), implying it can be undefined. If the AppRouteGraphRoute type guarantees rootParamNames is always populated (even if empty), the fallback is misleading. If it can genuinely be undefined, the type should reflect that.

Looking at usage: computeRootParamNames always returns string[] (never undefined), but the field on AppRoute may be optional depending on how the route was constructed. The fallback is safe — just noting the implicit type gap.

* @internal
*/
export async function appRouter(
export async function appRouteGraph(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The @internal JSDoc tag and TODO(#726) comment are good. One thing worth confirming: since app-router.ts re-exports are pulled into index.ts (line 11), and index.ts only imports appRouter and invalidateAppRouteCache, the appRouteGraph function is reachable only via deep import (vinext/routing/app-router). The @internal tag signals this correctly.

If the package's exports map in package.json restricts deep imports, appRouteGraph would be truly unreachable from external consumers — which is the right posture for scaffolding. Worth verifying the exports map doesn't inadvertently expose ./routing/*.

// locale-sensitive sorting. Keep the patched window scoped to graph building.
Object.defineProperty(String.prototype, "localeCompare", {
configurable: true,
value(this: string, compareString: string) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The monkey-patched localeCompare reverses the argument order via Reflect.apply(originalLocaleCompare, compareString, [this]) — swapping this and compareString. This is a clever way to invert sort order without changing the function signature.

One edge case: if localeCompare is called with additional arguments (locale, options), this patch would drop them since [this] only passes one arg. For the purpose of this test that's fine since no production code in the graph builder calls localeCompare at all (which is the whole point). Just flagging for completeness.

const manifest = graph.routeManifest;
const segmentGraph = manifest.segmentGraph;

expect(manifest.graphVersion).toMatch(/^graph:[a-f0-9]{64}$/);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good: the regex asserts the SHA-256 format (graph: prefix + 64 hex chars). This locks the format, which is appropriate for a test that validates determinism. If the format ever changes (e.g., switching hash algorithm), this test will catch it immediately — that's a feature, not a bug.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 6, 2026

Review posted. Here's a summary:

Overall: Clean, well-scoped Layer 3 read model for #726. The author has addressed all substantive feedback from the five prior bonk reviews (SHA-256 for graph version, Flavor types, @internal annotation, slot asymmetry documentation, root boundary invariant guards). The implementation is sound.

Key observations posted:

  1. graphVersion format commitment (app-route-graph.ts:440): The graph: prefix + SHA-256 format is now tested against a regex. This locks the format — worth considering whether the test should assert opaqueness or format, depending on whether Layer 4 will treat it as opaque.

  2. templateTreePositions optional chaining asymmetry (app-route-graph.ts:360): The template loop uses ?. while the layout loop doesn't — correct per the types, but the visual inconsistency is easy to miss.

  3. rootParamNames defensive fallback (app-route-graph.ts:310): The ?? [] fallback implies the field can be undefined, but computeRootParamNames always returns an array. Safe but slightly misleading.

  4. appRouteGraph export reachability (app-router.ts:50): Confirmed it's only reachable via deep import, not the package's public surface. The @internal tag signals this correctly.

  5. localeCompare monkey-patch edge case (tests/app-route-graph.test.ts:64): Drops additional localeCompare arguments — fine since the test's purpose is proving the graph builder doesn't use localeCompare at all.

  6. Graph version format test (tests/app-route-graph.test.ts:303): Good — locking the SHA-256 format is appropriate for determinism validation.

No blocking issues. This is ready to merge pending CI.

github run

The App Router graph now exposes root-boundary identity and a stable RouteManifest read model instead of leaving these facts implicit inside per-route arrays. Future cloudflare#726 planner and compatibility work needs a semantic lookup surface for routes, layouts, pages, templates, slots, and root boundaries before it can stop reading meaning from transport keys.

This adds RootBoundaryId, graphVersion, StaticSegmentGraph maps, and fail-fast invariant checks for mismatched layout/template positions while preserving the existing routes array for current callers.
@NathanDrake2406 NathanDrake2406 force-pushed the nathan/726-graph-02-route-manifest branch from e04749e to e23da31 Compare May 6, 2026 15:11
@NathanDrake2406
Copy link
Copy Markdown
Contributor Author

Addressed the only latest-review item with a real code implication in e23da319: AppRouteGraphRoute.rootParamNames is now required on the graph-owned route type, so the manifest read model no longer needs the misleading ?? [] fallback.

Also verified the appRouteGraph() reachability observation: packages/vinext/package.json does not expose ./routing/*, so the function is not part of the external package exports surface; the existing @internal tag is the right signal for repo-internal #726 scaffolding.

Validation:

  • vp check packages/vinext/src/routing/app-route-graph.ts tests/app-route-graph.test.ts
  • vp test run tests/app-route-graph.test.ts tests/routing.test.ts tests/entry-templates.test.ts tests/app-elements.test.ts
  • commit hook: vp check --fix, knip --no-progress, vp pack

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: feat(router): expose route graph manifest read model

This is a clean, well-scoped Layer 3 deliverable for #726. The prior five bonk reviews covered the surface thoroughly, and the author has addressed every substantive piece of feedback across three follow-up commits (SHA-256 for graphVersion, Flavor branded types, @internal annotation, required rootParamNames on the graph route type, slot boundary-agnostic comment, root boundary invariant guards).

What looks good

  • Thin read model: The manifest is derived post-validation/sort and contains only stable semantic facts. No planner, cache, or transport concerns leak in.
  • Fail-fast invariants: assertRouteManifestRootBoundary catches shared-layout root boundary divergence at build time rather than silently last-writer-wins. assertRouteManifestTreePosition catches ID/tree-position metadata drift.
  • Defensive copies: [...route.patternParts], [...route.params], etc. decouple the manifest from mutable route objects.
  • Backward-compatible: appRouter() delegates to appRouteGraph() with a shared cache. All existing callers (index.ts, run-prerender.ts, nitro-route-rules.ts, report.ts) still call appRouter() and get AppRouteGraphRoute[] -- no breaking change.
  • rootParamNames made required on AppRouteGraphRoute: Clean type narrowing via Omit + re-declaration. computeRootParamNames always returns string[], so the optional-to-required promotion is safe.
  • SHA-256 via node:crypto: Correct collision resistance, follows the project's "prefer Node built-ins" convention. The graph: prefix + 64-char hex format is tested and deterministic.
  • Test coverage: Determinism, locale insensitivity, distinct root boundaries, layoutless routes, cross-filesystem-root stability, route handler entries, caching identity. The localeCompare monkey-patch is a creative proof that compareStableStrings is locale-agnostic.

Minor observations (non-blocking)

  1. sortedMapValues key widening (app-route-graph.ts:439): sortedMapValues is typed as ReadonlyMap<string, T>, but segmentGraph.rootBoundaries is ReadonlyMap<RootBoundaryId, T>. This works because Flavor<string, ...> extends string, but the branded key is silently widened. If a future refactor changes Flavor to a true opaque type, this call would break. Not blocking -- just noting the generic could accept a looser key constraint.

  2. Sort style inconsistency in test (tests/app-route-graph.test.ts:384): .sort() with no comparator vs explicit codepoint comparator (lines 375-381) use different styles for the same intent. Both are correct for ASCII strings, just visually inconsistent.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 6, 2026

Review posted. Here's a summary:

Approved. This is a clean, well-scoped Layer 3 deliverable for #726. The author has addressed all substantive feedback from the five prior bonk reviews across three follow-up commits.

Key strengths:

  • Thin read model derived post-validation/sort with only stable semantic facts
  • Fail-fast invariants (assertRouteManifestRootBoundary, assertRouteManifestTreePosition)
  • Defensive copies decoupling the manifest from mutable route objects
  • Backward-compatible -- all existing callers still use appRouter() unchanged
  • SHA-256 via node:crypto for graphVersion with proper collision resistance
  • Solid test coverage: determinism, locale insensitivity, distinct root boundaries, layoutless routes, cross-filesystem-root stability, route handler entries, caching identity

Two non-blocking observations:

  1. sortedMapValues silently widens the RootBoundaryId branded key to string -- would break if Flavor ever becomes a true opaque type
  2. Minor sort style inconsistency in the test (no-arg .sort() vs explicit codepoint comparator)

github run

@james-elicx james-elicx merged commit ebbf72c into cloudflare:main May 6, 2026
24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants