Skip to content

feat(app-router): mint semantic route graph ids#1060

Merged
james-elicx merged 1 commit intocloudflare:mainfrom
NathanDrake2406:nathan/726-graph-semantic-ids
May 5, 2026
Merged

feat(app-router): mint semantic route graph ids#1060
james-elicx merged 1 commit intocloudflare:mainfrom
NathanDrake2406:nathan/726-graph-semantic-ids

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

What this changes

Implements #726-GRAPH-01 from #726 by having the existing App Router route graph mint stable semantic IDs for route, page, route handler, layout, template, and parallel slot facts.

The generated RSC manifest now carries those graph-owned IDs through route and slot metadata, but this PR intentionally does not promote planner decisions, root-boundary IDs, cache compatibility, or wire-key enforcement. Those are later #726-GRAPH-*, #726-CORE-*, and #726-WIRE-* tasks.

Why

The current flat payload bridge works, but route meaning is still reconstructed from route patterns, tree paths, and slot keys in later layers. For the #726 migration, topology identity needs to be a build-time graph fact before the planner and compatibility layers can stop treating wire keys as semantic authority.

Correctness oracle: Vinext internal invariant. Equivalent app filesystem topology should produce the same semantic IDs regardless of absolute filesystem root, while route groups and dynamic segment markers that affect topology must remain visible in graph IDs.

Approach

  • Added AppRouteSemanticIds to the existing route graph output.
  • Minted IDs from the existing graph facts: route pattern, page and route-handler presence, route segments, layout/template tree positions, and parallel slot ownership.
  • Added slot-level semantic IDs at discovery time and preserved the route ids plus slot id in generated App Router RSC route metadata.
  • Kept the constructors local for now because #726-GRAPH-01 only needs minted graph facts. Exporting canonical constructors can happen when a later PR has an actual import boundary for them.

Validation

  • vp test run tests/app-route-graph.test.ts tests/entry-templates.test.ts
  • vp check packages/vinext/src/routing/app-route-graph.ts packages/vinext/src/entries/app-rsc-manifest.ts packages/vinext/src/server/app-page-route-wiring.tsx tests/app-route-graph.test.ts tests/entry-templates.test.ts
  • vp run knip --no-progress
  • vp staged
  • vp test run tests/entry-templates.test.ts -u

Note: the pre-commit hook currently assumes tests/__snapshots__/entry-templates.test.ts.snap exists and fails when entry-template files are staged, even after the snapshot update test passes. I ran the hook checks above manually and committed with --no-verify for that broken hook path.

Risks / follow-ups

This PR preserves current runtime behavior. The new IDs are carried as metadata only; later #726 PRs still need to define root boundary IDs, RouteManifest read models, wire-key fences, planner contracts, and compatibility envelopes before semantic decisions move out of current runtime paths.

Refs #726

Copilot AI review requested due to automatic review settings May 5, 2026 06:18
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 5, 2026

Open in StackBlitz

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

commit: ebd6f72

@NathanDrake2406 NathanDrake2406 force-pushed the nathan/726-graph-semantic-ids branch from 95babdf to 9656dd7 Compare May 5, 2026 06:27
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-semantic-ids branch from 9656dd7 to f4ee21b Compare May 5, 2026 06:45
@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(app-router): mint semantic route graph ids

Solid, well-scoped first step for #726-GRAPH-01. Tests pass, types check, and the new IDs are carried as inert metadata without touching any runtime paths. The approach of minting IDs from existing graph facts (pattern, tree positions, slot ownership) is clean and produces stable, root-independent identifiers.

A few observations and one thing worth resolving before merge:

Key concern

ids.slots map keys are slot.key (relative filesystem path), not semantic IDs. The values in ids.slots are proper graph-owned semantic IDs (slot:modal:/(marketing)/blog/[slug]), but the keys are slot.key strings like modal@(marketing)/blog/[slug]/@modal — which embed the @-directory name and relative filesystem structure. This means the AppRouteSemanticIds.slots record isn't purely semantic; it's a mapping from an infrastructure key to a semantic ID. That's fine as a bridge, but worth a doc comment on the slots field in AppRouteSemanticIds explaining this is keyed by slot.key (an infrastructure identifier) intentionally, and that later #726 work may re-key it.

Minor items

Inline comments below.

routeHandler: string | null;
layouts: readonly string[];
templates: readonly string[];
slots: Readonly<Record<string, 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 slots field is keyed by slot.key (the infrastructure-level name@relative-path identifier), not by the graph-owned semantic slot ID. This is a reasonable bridge for #726-GRAPH-01, but it's the one place in AppRouteSemanticIds where a non-semantic key leaks in. Consider adding a doc comment so a later PR knows whether to re-key this by semantic ID or keep the slot.key → semantic-ID mapping.

Suggested change
slots: Readonly<Record<string, string>>;
slots: Readonly<Record<string, string>>;
// ↑ Keyed by slot.key (infrastructure id: "name@relative/path"), value is graph-owned semantic slot id.
// Later #726 PRs may re-key by semantic id once the planner consumes these.

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.

Documented this bridge map on AppRouteSemanticIds.slots so later #726 work can see it is currently slot.key -> semantic slot id.

}): AppRouteSemanticIds {
const slots: Record<string, string> = {};
for (const slot of input.slots) {
if (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.

Since AppRouteGraphParallelSlot makes id required (non-optional string), this truthiness check is always true for graph slots. The if (slot.id) guard only matters for the base ParallelSlot type where id is optional. Since the input type here is readonly AppRouteGraphParallelSlot[], the guard is dead code.

Not a bug — it's defensive — but it could be confusing. Consider either removing the check (since the type guarantees presence) or adding a comment explaining why it's there.

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.

Removed the guard. createAppRouteSemanticIds() now accepts graph slots, so AppRouteGraphParallelSlot enforces the required id invariant.

Comment on lines +168 to +190
function createAppRouteGraphRouteId(pattern: string): string {
return `route:${pattern}`;
}

function createAppRouteGraphPageId(pattern: string): string {
return `page:${pattern}`;
}

function createAppRouteGraphRouteHandlerId(pattern: string): string {
return `route-handler:${pattern}`;
}

function createAppRouteGraphLayoutId(treePath: string): string {
return `layout:${treePath}`;
}

function createAppRouteGraphTemplateId(treePath: string): string {
return `template:${treePath}`;
}

function createAppRouteGraphSlotId(slotName: string, ownerTreePath: string): string {
return `slot:${slotName}:${ownerTreePath}`;
}
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.

These six constructor functions are clean and local, which is fine for this PR. One thought for a follow-up: the ID format uses : as a separator (slot:modal:/(marketing)/blog/[slug]), which means a slot name containing : would be ambiguous. Real-world Next.js slot names can't contain : (they're directory names starting with @), so this is safe in practice, but it might be worth documenting the format contract (or adding a debug-mode assertion) before later PRs start parsing these IDs.

);

expect(routeEntry).toBeDefined();
expect(routeEntry).not.toContain(appDir);
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 assertion — verifying semantic IDs don't leak the filesystem root. Note that routeEntry also contains the slot.key values (e.g. "modal@(marketing)/blog/[slug]/@modal") which are relative to appDir, so they won't match appDir either. The assertion holds, but it's testing two things at once (semantic IDs don't contain appDir + slot keys don't contain appDir). That's fine for now, just noting it.

TModule extends AppPageModule = AppPageModule,
TErrorModule extends AppPageErrorModule = AppPageErrorModule,
> = {
ids?: AppRouteSemanticIds | 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.

Good that this is ids?: AppRouteSemanticIds | null rather than required — keeps backward compatibility with routes that don't have IDs yet (e.g. hand-crafted test fixtures or older codepaths). The null union is appropriate since the manifest serializes route.ids ?? null.

Comment on lines +709 to +718
function createAppRouteGraphTreePath(
routeSegments: readonly string[],
treePosition: number,
): string {
const treePathSegments = routeSegments.slice(0, treePosition);
if (treePathSegments.length === 0) {
return "/";
}
return `/${treePathSegments.join("/")}`;
}
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 function is straightforward but has a subtle interaction with route groups: routeSegments.slice(0, treePosition) includes route group segments like (marketing) in the tree path. That's intentional per the PR description ("route groups and dynamic segment markers that affect topology must remain visible in graph IDs"), and the tests verify it. Just confirming this is the intended behavior since it differs from the URL pattern (where route groups are invisible).

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 5, 2026

Review submitted successfully. Here's a summary of the review:

Overall assessment: Clean, well-scoped PR that implements #726-GRAPH-01 correctly. Tests pass, types check, and the new semantic IDs are carried as inert metadata without affecting any runtime paths.

Key concern raised:

  • The ids.slots map in AppRouteSemanticIds is keyed by slot.key (an infrastructure identifier with relative filesystem paths) rather than by semantic IDs. This is a reasonable bridge for this PR, but should be documented so later PRs know whether to re-key it.

Minor observations:

  1. The if (slot.id) guard in createAppRouteSemanticIds is dead code since AppRouteGraphParallelSlot makes id required
  2. The ID format uses : as separator — safe in practice since slot names are directory names, but worth documenting the format contract before later PRs start parsing IDs
  3. Confirmed route groups are intentionally visible in tree paths (topology-affecting), which is correct per the design
  4. The ids? optional typing in AppPageRouteWiringRoute correctly maintains backward compatibility
  5. The "no filesystem root leak" test assertion is sound

github run

App Router route graph entries previously left route, page, layout, template, and slot identity to later render and wire layers. That kept topology meaning coupled to reconstructed payload keys instead of a graph-owned fact.

Mint stable semantic IDs during graph construction, preserve them in generated RSC route metadata, and cover route-group, dynamic-segment, slot-owner, and filesystem-root stability cases.
@NathanDrake2406 NathanDrake2406 force-pushed the nathan/726-graph-semantic-ids branch from f4ee21b to ebd6f72 Compare May 5, 2026 16:47
@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(app-router): mint semantic route graph ids

All tests pass, types check, and the new semantic IDs are carried as inert metadata without touching any runtime codepaths. The approach is clean — IDs are minted from existing graph facts at scan time, the type hierarchy (AppRouteGraphRoute extends AppRoute) preserves backward compatibility for all existing consumers, and the two test additions (stability across filesystem roots + no root leak in serialized manifest) are the right assertions for this layer.

The previous review's feedback has been addressed (dead if (slot.id) guard removed, doc comment added to slots bridge map). No new concerns.

Verified

  • vp test run tests/app-route-graph.test.ts — 13 tests pass
  • vp test run tests/entry-templates.test.ts — 10 tests pass
  • vp check on all changed files — clean (format + lint + types)
  • ids and slot.id are only referenced during manifest serialization, never in request-handling runtime code
  • AppRouteGraphRoute[] is assignable to AppRoute[] — no downstream breakage
  • Inherited slots preserve id through the spread in discoverInheritedParallelSlots
  • applySlotSubPages mutations don't invalidate ids.slots (slot keys are stable, only pagePath/routeSegments change)

One nit (non-blocking)

The routesByPattern map in discoverSlotSubRoutes (line 339) holds AppRouteGraphRoute values but is typed as Map<string, AppRoute>, and applySlotSubPages takes AppRoute. The downcast is harmless — the mutation only touches parallelSlots via spread (preserving id), and ids.slots remains valid since slot keys don't change. But narrowing the map and function parameter types to AppRouteGraphRoute would let the compiler catch any future mutations that might drop graph-specific fields.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 5, 2026

Review submitted successfully. Here's a summary:

Approved — this is a well-scoped, clean implementation of #726-GRAPH-01. The semantic IDs are minted from existing graph facts at scan time and carried as inert metadata without touching any runtime request-handling code. All tests pass (13 in app-route-graph, 10 in entry-templates), types check clean, and the previous review feedback has been addressed.

Key things verified:

  • ids and slot.id are only referenced during manifest serialization — zero runtime impact
  • AppRouteGraphRoute is a proper subtype of AppRoute — no downstream breakage
  • Inherited slots preserve id through spread in discoverInheritedParallelSlots
  • applySlotSubPages mutations don't invalidate ids.slots since slot keys are stable

One non-blocking nit: the routesByPattern map in discoverSlotSubRoutes could be narrowed from Map<string, AppRoute> to Map<string, AppRouteGraphRoute> for better type safety going forward.

github run

@james-elicx james-elicx merged commit 834ccc6 into cloudflare:main May 5, 2026
28 checks passed
@NathanDrake2406 NathanDrake2406 deleted the nathan/726-graph-semantic-ids branch May 6, 2026 04:30
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