Skip to content

Add native App Router route type generation#1144

Open
JamesbbBriz wants to merge 13 commits into
cloudflare:mainfrom
JamesbbBriz:feature/native-route-typegen
Open

Add native App Router route type generation#1144
JamesbbBriz wants to merge 13 commits into
cloudflare:mainfrom
JamesbbBriz:feature/native-route-typegen

Conversation

@JamesbbBriz
Copy link
Copy Markdown
Contributor

Summary

  • Add native App Router route type generation for PageProps, LayoutProps, and RouteContext.
  • Generate .next/types/routes.d.ts from Vinext's existing App Router route graph.
  • Refresh generated route types during dev when App Router files are added or removed.

Closes #664

Test Plan

  • NODE_OPTIONS=--experimental-strip-types pnpm exec vp check --fix
  • pnpm test tests/typegen.test.ts tests/check.test.ts
  • pnpm exec tsc --noEmit --pretty false from packages/vinext
  • pnpm --filter vinext run build
  • git diff --check
  • Smoke-tested built vinext typegen against a temp App Router project

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 9, 2026

Open in StackBlitz

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

commit: e06d6d7

@JamesbbBriz JamesbbBriz force-pushed the feature/native-route-typegen branch from 80ea35a to 92c2c5f Compare May 9, 2026 00:55
@JamesbbBriz JamesbbBriz marked this pull request as ready for review May 9, 2026 01:09
Copy link
Copy Markdown
Contributor Author

This is ready for maintainer review now that checks have passed.

The implementation intentionally follows the native generation direction rather than delegating to next typegen, based on the discussion in #651 and #664. I’d especially appreciate review on the generated helper type surface and the integration points with the existing App Router route graph / dev invalidation flow.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 92c2c5fa33

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/vinext/src/typegen.ts
Comment thread packages/vinext/src/cli.ts Outdated
@JamesbbBriz
Copy link
Copy Markdown
Contributor Author

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6d40deda6e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/vinext/src/cli.ts Outdated
@JamesbbBriz
Copy link
Copy Markdown
Contributor Author

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a053daa927

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/vinext/src/typegen.ts Outdated
@JamesbbBriz
Copy link
Copy Markdown
Contributor Author

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f359f5f0fc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/vinext/src/index.ts Outdated
@JamesbbBriz
Copy link
Copy Markdown
Contributor Author

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e0b73ef8b3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/vinext/src/typegen.ts Outdated
@JamesbbBriz
Copy link
Copy Markdown
Contributor Author

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: de4bcab749

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/vinext/src/typegen.ts Outdated
Comment on lines +81 to +82
const layoutRoute = treePathToRouteLiteral(slot.ownerTreePath);
const slots = model.layoutSlots.get(layoutRoute) ?? [];
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep layout slot types separate across root route groups

layoutSlots is keyed by treePathToRouteLiteral(slot.ownerTreePath), which strips route-group segments. In apps with multiple root layouts (e.g. app/(shop)/layout and app/(marketing)/layout), both owners collapse to "/", so their slot names are merged into one LayoutProps<'/'> signature. That makes helper types inaccurate (slots from one root layout appear on another), which is exactly where developers rely on these generated types for correctness.

Useful? React with 👍 / 👎.

@JamesbbBriz
Copy link
Copy Markdown
Contributor Author

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6821754dee

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/vinext/src/typegen.ts Outdated
Comment on lines +82 to +83
const layoutRoute =
layoutRouteKeys.get(slot.ownerTreePath) ?? treePathToRouteLiteral(slot.ownerTreePath);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Map slots to owning layout route instead of owner tree path

LayoutSlotMap is keyed by LayoutRoute, but this loop keys slots by slot.ownerTreePath ("/foo" in app/foo/@slot/*) rather than the slot’s ownerLayoutId. When a slot is defined under a segment that does not have its own layout.tsx, the manifest can still assign it to an ancestor layout via ownerLayoutId; with the current code, the slot is written under a non-layout key and then dropped from the emitted LayoutSlotMap, so LayoutProps<'/...'> misses that slot prop. Use ownerLayoutId (resolved back to the layout route key) as the source of truth for slot ownership.

Useful? React with 👍 / 👎.

@JamesbbBriz
Copy link
Copy Markdown
Contributor Author

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7006ccb0cf

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/vinext/src/typegen.ts Outdated
const segments = treePath
.split("/")
.filter(Boolean)
.filter((segment) => !isSlotSegment(segment) && segment !== ".")
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve slot segments when disambiguating layout route keys

When two layouts resolve to the same URL route, this fallback key path is supposed to disambiguate them, but treePathToScopedLayoutRouteLiteral removes @slot segments, so layouts like app/layout.tsx and app/@modal/layout.tsx both collapse to "/". That causes LayoutRoute de-duplication and merges slot ownership in LayoutSlotMap, producing incorrect LayoutProps typings for slot-local layouts (missing or extra slot props).

Useful? React with 👍 / 👎.

@JamesbbBriz
Copy link
Copy Markdown
Contributor Author

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bcc3a36b17

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +24 to +25
await fs.mkdir(path.dirname(outPath), { recursive: true });
await fs.writeFile(outPath, content, "utf-8");
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Generate next-env.d.ts alongside route types

generateRouteTypes only writes .next/types/routes.d.ts, but vinext typegen is intended to be a standalone pre-typecheck step and many Next-style TS configs include next-env.d.ts (usually gitignored). In a fresh checkout where that file is absent, running vinext typegen && tsc --noEmit still fails because the env declaration file is never regenerated, so the command does not fully satisfy the expected typegen workflow.

Useful? React with 👍 / 👎.

@JamesbbBriz
Copy link
Copy Markdown
Contributor Author

Hi @james-elicx, this is ready for human review when you have a moment.

  • All CI green; prior Codex P1/P2 rounds addressed (slot ownership via ownerLayoutId, slot-segment preservation in disambiguation, layout params derived from manifest, explicit LayoutProps generic, serialized dev regeneration, decoded layout literals).
  • Latest commit 5900749 adds Next-compatible next-env.d.ts stub generation so the standalone vinext typegen && tsc --noEmit workflow works on a fresh checkout (existing files are preserved; covered by two new tests in tests/typegen.test.ts).
  • The latest Codex P2 about treePathToRouteLiteral() decoding appears to be a false positive — decodeRouteSegment is already applied at packages/vinext/src/typegen.ts and the app/%5Fsites/* case is exercised by the first generateRouteTypes test.

Happy to split this into smaller PRs (e.g. CLI command vs dev-mode integration vs layout slot wiring) if that would make review easier.

@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: Add native App Router route type generation

Overall this is solid work — the type model design is clean, the layout disambiguation via createLayoutRouteKeyMap is well thought out, the serialized dev regeneration handles rapid watcher bursts correctly, and the test coverage is thorough (slot ownership, route groups, escaped segments, dev-mode invalidation). The Codex review rounds addressed all the major issues I would have flagged.

A few things I'd like to see addressed before merge:

Issues

  1. addRoute uses Array.includes for dedup — O(n) per insertion makes overall route collection O(n²). For typical apps this is fine, but it's a latent scaling issue. A Set alongside the array would be cleaner.

  2. Duplicate isInvisibleSegmenttypegen.ts re-implements isInvisibleSegment identically to the one in app-route-graph.ts. This is a maintenance risk — if the canonical definition changes (e.g. intercepting route segments become invisible), the typegen copy drifts silently. Consider exporting it from the routing module.

  3. next-env.d.ts references next types — The generated stub includes /// <reference types="next" /> and /// <reference types="next/image-types/global" />. For vinext projects that don't have next installed as a dependency, this will cause TS errors. Should this reference vinext's own type declarations instead, or should the stub be conditional on whether next is actually installed?

  4. Race in drainAppRouteTypeGeneration finally block — The finally callback sets appRouteTypeGeneration = null then checks appRouteTypeGenerationPending and re-invokes regenerateAppRouteTypes(). But between null assignment and the if check, there's no actual race (single-threaded), so this is fine. However, the pattern is a bit subtle — a brief comment explaining why the finally re-check is needed would help future readers.

See inline comments for specifics.

if (!routes.includes(route)) {
routes.push(route);
routes.sort(compareStrings);
}
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.

O(n) includes on every insertion makes this O(n²) overall. For typical apps with <100 routes this doesn't matter, but a Set alongside the sorted array would be cleaner and avoid the latent scaling issue:

Suggested change
}
function addRoute(
routes: string[],
params: Map<string, ParamShape>,
route: string,
paramShape: ParamShape,
): void {
if (!params.has(route)) {
routes.push(route);
routes.sort(compareStrings);
}
params.set(route, paramShape);
}

Since params is already keyed by route, you can use it as the dedup check and avoid the linear scan entirely. (The sort-on-every-push is also O(n² log n) total — could do a single sort at the end of collectRouteTypeModel — but that's a much smaller concern.)

Comment thread packages/vinext/src/typegen.ts Outdated
return (
segment === "." || (segment.startsWith("(") && segment.endsWith(")")) || segment.startsWith("@")
);
}
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 is an exact copy of the isInvisibleSegment in app-route-graph.ts:1860. Duplicating this definition is a maintenance risk — if the canonical definition in the routing module changes (e.g., intercepting route prefixes become invisible), this copy won't be updated.

Consider exporting isInvisibleSegment from app-route-graph.ts (or a shared routing util) and importing it here.


const NEXT_ENV_FILE_CONTENT = `/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
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 import "./.next/types/routes.d.ts" line uses a relative import from the project root, which means next-env.d.ts must live at the project root for this to resolve. That's the standard location, so it works, but it's worth a comment explaining the coupling.

More importantly: /// <reference types="next" /> will fail in vinext projects that don't have next installed. Is the intent that next is always a peer dep? If not, this stub should either reference vinext's own type declarations or be conditional on whether next is resolvable.

This is a nit for now since migrated projects will have next installed, but it'll bite fresh vinext-only projects.

appRouteTypeGeneration = drainAppRouteTypeGeneration().finally(() => {
appRouteTypeGeneration = null;
if (appRouteTypeGenerationPending) regenerateAppRouteTypes();
});
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 finally block sets appRouteTypeGeneration = null then re-checks appRouteTypeGenerationPending. Since JS is single-threaded, there's no actual race here, but the pattern is subtle. A brief comment explaining why the re-check is needed would help:

Suggested change
});
appRouteTypeGeneration = drainAppRouteTypeGeneration().finally(() => {
appRouteTypeGeneration = null;
// An event may have arrived after drain finished but before we cleared
// the in-flight promise. Re-check and restart if so.
if (appRouteTypeGenerationPending) regenerateAppRouteTypes();
});

params: Promise<VinextRouteTypes.ParamMap[Route]>;
children: React.ReactNode;
} & {
[K in VinextRouteTypes.LayoutSlotMap[Route]]: React.ReactNode;
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: The { mapped type brace is on the same line as the & { intersection. This is correct but dense. More importantly, when LayoutSlotMap[Route] resolves to never (no slots), the intersection & { [K in never]: ReactNode } produces & {} which is fine — just verifying this is intentional and tested (it is, via the "/@modal": never assertion).


for (const slot of route.parallelSlots) {
const ownerLayoutId = findSlotOwnerLayoutId(route, slot);
if (slot.layoutPath) {
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.

Nice — materializing slot-local layouts in the segment graph is the right call. This means app/@modal/layout.tsx gets its own layout entry with a treePath of /@modal, which keeps it distinct from the root layout in typegen's LayoutRoute union.

One question: if a slot has a layoutPath but its ownerLayoutId points to an ancestor, the slot-local layout gets registered here but the slot itself (line 465) still references the ancestor via ownerLayoutId. This is correct for typegen (slot props go to the ancestor), but could it confuse other consumers of the segment graph that iterate layouts and expect every layout to have at least one slot? Worth a brief comment documenting that slot-local layouts may have zero slots in the manifest.

root,
mode: "production",
});
const resolvedNextConfig = await resolveNextConfig(
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: resolvedNextConfig loads both loadNextConfig and resolveNextConfig, which reads the config file and resolves paths. If the project has no next.config.{js,ts,mjs}, does loadNextConfig throw or return defaults? If it throws, the error message won't be very helpful for a typegen command. Consider catching and providing a friendlier message like "No next.config found; using defaults".

Comment thread tests/typegen.test.ts

let server: ViteDevServer | null = null;
try {
server = await createServer({
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 dev-mode test passes { appDir: root } to vinext(), which sets appDir to the project root rather than root + '/app'. This works because the plugin detects the app/ subdirectory, but it's a bit confusing — at first glance it looks like the app dir is the root. Consider passing path.join(root, 'app') for clarity, or add a comment explaining why root works here.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 11, 2026

Review posted. Here's a summary of what I found:

The PR is well-structured — it adds a clean typegen.ts module that generates PageProps, LayoutProps, and RouteContext types from vinext's existing App Router route graph, with a CLI command, dev-mode auto-regeneration, and thorough tests. The 10 commits show good iterative improvement (Codex rounds addressed slot ownership, layout param derivation, serialized regeneration, and scoped route groups).

Key feedback items:

  1. Duplicate isInvisibleSegment — The copy in typegen.ts should import from the routing module instead of re-implementing it, to avoid silent drift.

  2. addRoute O(n²) dedup — Uses Array.includes per insertion; can use the already-present params Map as the dedup set instead.

  3. next-env.d.ts references next types — Will fail for vinext-only projects that don't have next installed. Needs a decision on whether this should reference vinext types or be conditional.

  4. Serialization comment — The finally re-check pattern in drainAppRouteTypeGeneration is correct but subtle; suggested adding a clarifying comment.

  5. Minor nits on test clarity (appDir: root vs appDir: root + '/app') and loadNextConfig error handling in the typegen CLI command.

github run

@JamesbbBriz
Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review @james-elicx / @ask-bonk. Addressed in 2ce91c8:

  1. addRoute dedup — switched to per-list Set lookups. Side-finding while doing this: the params Map can't be used as the dedup set because it's shared across page/layout/handler routes; using it would silently drop layouts whose route already exists as a page (e.g. /). Caught by the existing slot-local layout test once I tried that path.
  2. Duplicate isInvisibleSegment — exported from routing/app-route-graph.ts and imported in typegen.ts. Single source of truth now.
  3. Drain finally re-check — added explanatory comment at packages/vinext/src/index.ts:2085.
  4. Slot-local layouts may have zero slots — added a documenting comment at packages/vinext/src/routing/app-route-graph.ts:444 explaining the ownerLayoutId redirection.
  5. appDir: root in dev test — added a clarifying comment about the plugin option naming the project root and auto-detecting app/.

Deferred (happy to follow up if you'd prefer them in this PR):

  • next-env.d.ts next type references — kept as-is to match the existing convention in examples/app-router-playground/next-env.d.ts and examples/realworld-api-rest, both of which use the same <reference types="next" /> lines and rely on skipLibCheck: true. Switching to vinext-specific references here would diverge from those examples. Worth a follow-up that updates the examples in lockstep.
  • Friendlier loadNextConfig error message in vinext typegen — Next-style projects without next.config are rare; the current path returns defaults rather than throwing, so the error message Codex was worried about doesn't actually surface in practice. Happy to add a try/catch if you'd still like it.

The LayoutSlotMap[Route] = never intersection is intentional and exercised by the '/@modal': never assertion as noted.

…e-typegen

# Conflicts:
#	packages/vinext/src/routing/app-route-graph.ts
@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: Add native App Router route type generation

This is well-designed work. The type model is clean, the layout disambiguation via createLayoutRouteKeyMap handles the tricky route-group collision cases correctly, the serialized drain loop for dev regeneration is sound, and the test coverage is thorough across slot ownership, escaped segments, route groups, and dev-mode invalidation. The iterative Codex rounds addressed all the major structural issues (slot ownership, layout params from manifest, scoped route groups, serialized writes).

I have a few remaining items — two that should be fixed before merge, and several minor observations.

Should fix

  1. addRoute sorts on every insertionroutes.sort(compareStrings) runs after every push, making the total cost O(n² log n) across all routes. Since the sort only matters for the final output, deferring it to after collection completes (or using insertion sort for O(n²) but better constants) would be cleaner. Not a correctness issue, but it's unnecessary work and the pattern is easy to misread as intentional. See inline comment.

  2. Slot name dedup uses Array.includes — Same pattern at line 116. Slot lists per layout are small so this is even less of a practical concern, but the inconsistency with the route dedup (which uses Set) stands out. Consider using a Set here too for consistency.

Minor / nits

  1. treePathToScopedLayoutRouteLiteral only filters . — The disambiguation function keeps @slot and (group) segments, which is correct for distinguishing app/layout.tsx from app/@modal/layout.tsx. But it also keeps route-group segments like (marketing), which means two layouts under different groups at the same path disambiguate correctly too. Just want to confirm this is the intentional design — the "/(marketing)" test assertion suggests it is.

  2. writeRouteTypes during configResolved — This runs await writeRouteTypes() at plugin config time (line 875), which means the initial typegen runs synchronously in the config resolution path. For large apps this could slow down Vite startup noticeably. Not blocking, but worth noting — if it becomes a problem, it could be deferred to configureServer with the first write happening asynchronously.

  3. next-env.d.ts references next types — Acknowledged in the PR thread as a known gap for vinext-only projects. The deferral reasoning (matching existing examples that use skipLibCheck: true) is reasonable. Tracking this as a follow-up is fine.

Comment thread packages/vinext/src/typegen.ts Outdated
if (!seen.has(route)) {
seen.add(route);
routes.push(route);
routes.sort(compareStrings);
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.

Sorting after every push makes addRoute O(n log n) per call and O(n² log n) total across all routes. Since the sorted order only matters for the rendered output, consider deferring the sort to after all routes are collected:

Suggested change
routes.sort(compareStrings);
if (!seen.has(route)) {
seen.add(route);
routes.push(route);
}

Then add a single routes.sort(compareStrings) call at the end of collectRouteTypeModel for each route list (or sort in renderRouteTypes). The current approach is correct but does unnecessary work.

Comment thread packages/vinext/src/typegen.ts Outdated
if (!layoutRoute) continue;

const slots = model.layoutSlots.get(layoutRoute) ?? [];
if (!slots.includes(slot.name)) {
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 uses Array.includes for slot name dedup while route dedup (line 308) uses a Set. For consistency, consider using a Set<string> here too, even though slot lists per layout are small enough that it doesn't matter for performance:

Suggested change
if (!slots.includes(slot.name)) {
const slots = model.layoutSlots.get(layoutRoute) ?? [];
if (!slots.includes(slot.name)) {
slots.push(slot.name);
}

Or better yet, track a Set alongside the array (same pattern as pageRouteSet/layoutRouteSet) and defer the sort to after the loop.

return segments.length === 0 ? "/" : `/${segments.join("/")}`;
}

function treePathToScopedLayoutRouteLiteral(treePath: 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.

This is a subtle but important distinction from treePathToRouteLiteral — it keeps @slot and (group) segments for disambiguation. The asymmetry between the two functions (one filters isInvisibleSegment, the other only filters .) is correct but easy to misread.

Consider a doc-comment explaining when this variant is used and why it preserves route-group/slot segments:

Suggested change
function treePathToScopedLayoutRouteLiteral(treePath: string): string {
/**
* Convert a layout tree path to a scoped route literal that preserves
* route-group and @slot segments. Used only for disambiguation when
* multiple layouts collapse to the same URL route literal.
*/
function treePathToScopedLayoutRouteLiteral(treePath: string): string {

return layoutRouteKeys.get(layout.treePath) ?? treePathToRouteLiteral(layout.treePath);
}

function treePathToRouteLiteral(treePath: 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.

Same suggestion — a brief doc-comment here would help readers understand the contract:

Suggested change
function treePathToRouteLiteral(treePath: string): string {
/** Convert a layout tree path to its URL route literal, stripping invisible segments. */
function treePathToRouteLiteral(treePath: string): string {

return outPath;
}

async function ensureNextEnvFile(root: string): Promise<void> {
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 ensureNextEnvFile check uses fs.access to test existence and silently catches to create. This is correct, but there's a TOCTOU window — if two concurrent generateRouteTypes calls race (e.g., during the dev drain loop), both could see the file as absent and write. In practice the content is idempotent so this is harmless, but fs.writeFile with { flag: 'wx' } (exclusive create) would be more precise:

Suggested change
async function ensureNextEnvFile(root: string): Promise<void> {
async function ensureNextEnvFile(root: string): Promise<void> {
const envPath = path.join(root, "next-env.d.ts");
try {
await fs.writeFile(envPath, NEXT_ENV_FILE_CONTENT, { flag: "wx" });
} catch (error: unknown) {
// File already exists — leave it untouched.
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
}
}

This is a nit — the current approach works fine.

instrumentationPath = findInstrumentationFile(root, fileMatcher);
instrumentationClientPath = findInstrumentationClientFile(root, fileMatcher);
middlewarePath = findMiddlewareFile(root, fileMatcher);
await writeRouteTypes();
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 await writeRouteTypes() runs in the configResolved hook, which means it blocks Vite startup. For most apps this is fast, but for large route trees the FS scan + write could add noticeable latency to vite dev startup.

Not blocking — just flagging that if startup perf becomes a concern, this could be moved to fire-and-forget in configureServer (since the types aren't needed for Vite to start serving).

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 12, 2026

Review posted successfully. Here's a summary of what I found:

Overall assessment: This is solid, well-iterated work. The type model design is clean, layout disambiguation via createLayoutRouteKeyMap correctly handles route-group collisions, the serialized drain loop for dev regeneration is sound, and test coverage is thorough.

Key feedback:

  1. addRoute sorts on every insertion (should fix) — routes.sort() after every push makes collection O(n² log n). Deferring the sort to after all routes are collected would be cleaner.

  2. Slot name dedup inconsistency (should fix) — Uses Array.includes while route dedup uses Set. Should use the same pattern for consistency.

  3. Missing doc-comments on disambiguation functions (nit) — treePathToRouteLiteral vs treePathToScopedLayoutRouteLiteral have subtle but important semantic differences that deserve doc-comments.

  4. ensureNextEnvFile TOCTOU (nit) — fs.access + fs.writeFile has a race window; fs.writeFile with { flag: 'wx' } would be more precise.

  5. writeRouteTypes blocks Vite startup (observation) — Runs in configResolved, which could add latency for large route trees. Flagged as a potential future optimization.

  6. next-env.d.ts references next types — Acknowledged deferral is reasonable given existing examples use skipLibCheck: true.

github run

@JamesbbBriz
Copy link
Copy Markdown
Contributor Author

Thanks @james-elicx / @ask-bonk. Addressed in e06d6d7:

  1. addRoute per-insertion sort — dropped the in-loop routes.sort(). collectRouteTypeModel now does a single sort pass over pageRoutes, layoutRoutes, routeHandlerRoutes, and each layoutSlots array after collection completes. Comment in place explaining why.
  2. Slot name dedup uses Set — slot collection now uses a parallel Map<string, Set<string>> for O(1) dedup, same pattern as the per-list route Sets. The deferred sort above covers slot ordering too.
  3. Doc comments on treePathToRouteLiteral / treePathToScopedLayoutRouteLiteral — added; calls out the asymmetric segment filter and that the scoped variant only fires as a disambiguation fallback.
  4. ensureNextEnvFile TOCTOU nit — switched to fs.writeFile(..., { flag: "wx" }) with an EEXIST swallow. Same behavior, no access/writeFile race window.

Skipped per your earlier framing:

  • writeRouteTypes in configResolved — agree this could become a Vite startup concern for very large route trees, but moving it to configureServer (fire-and-forget) changes the timing contract a bit (first tsc --noEmit after vite dev start could race the initial write). Happy to do it in a follow-up once we have a real signal; for now keeping the deterministic ordering.
  • next-env.d.ts next references — left as-is per the prior thread.

@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: Add native App Router route type generation

This is well-executed work. The type model design is clean, the layout disambiguation via createLayoutRouteKeyMap handles route-group collisions correctly, the serialized drain loop is sound, and the test coverage is thorough across the important cases (slot ownership, escaped segments, route groups, dev-mode invalidation, next-env.d.ts preservation).

The iterative review rounds addressed all the major structural issues. The current state of the code is solid — I have a few remaining observations (mostly minor) below.

Summary of items

  1. Unknown flags silently dropped (minor gap) — parseArgs now collects positionals for non-flag args, but unknown flags (e.g. --foobar) that don't match FLAG_PATTERN are silently ignored. Not introduced by this PR (existing behavior), but worth noting since typegen is the first command that relies on positionals.

  2. params map last-write-wins on duplicates (observation) — addRoute always calls params.set(route, paramShape) even when the route was already seen. If a page and layout both produce the same route string with different param shapes, the last writer wins silently. In practice page/layout/handler routes are collected from separate manifest entries with consistent shapes, so this is not a real concern today, but the contract is implicit.

  3. convertTreePathToRouteParts for slot paths (correctness check) — Calling convertTreePathToRouteParts("/@modal") splits to ["@modal"], then convertSegmentsToRouteParts skips it as invisible, yielding { urlSegments: [], params: [] }. This is correct — slot-local layouts have empty URL parts. Verified via the test assertion at tests/app-route-graph.test.ts:330-331.

  4. Dev regeneration error handlingwarnRouteTypeGenerationFailure logs and continues, which is the right call for dev. The drain loop won't propagate failures, so a transient FS error (e.g. full disk) won't crash the dev server. Good.

  5. No change watcher event — Regeneration hooks on add and unlink only. A change event on an existing route file (e.g. renaming from page.tsx to route.ts) won't trigger regeneration. This matches the existing invalidateAppRoutingModules pattern so it's consistent, but it's a latent gap — a user renaming page.tsx to route.ts would need to save again or restart dev.

See inline comments for specifics.

seen.add(route);
routes.push(route);
}
params.set(route, paramShape);
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.

Observation: params.set(route, paramShape) is an unconditional overwrite — if a route string appears in both pageRoutes and layoutRoutes (which it will for routes like "/" or "/dashboard"), the layout's param shape silently replaces the page's. Since layouts are processed after pages, this means the layout param shape wins.

In practice these should always agree (they're derived from the same route segments), but the implicit last-writer-wins contract is easy to miss. Consider either:

  • Only setting when the key is new (matching the seen.has guard), or
  • Adding a debug assertion that the shapes match when overwriting.

Not blocking — just a maintenance note.


type ParamShape = Map<string, "string" | "string[]" | "string[]?">;

const NEXT_ENV_FILE_CONTENT = `/// <reference types="next" />
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 generated next-env.d.ts content has /// <reference types="next" /> and /// <reference types="next/image-types/global" />. I understand the decision to defer (matching existing examples that use skipLibCheck: true), and the PR thread documents this clearly.

One thought for the follow-up: when vinext ships its own type declarations, the stub could conditionally reference vinext types when next isn't resolvable. This would make vinext typegen && tsc --noEmit work out-of-the-box for greenfield vinext projects.

);
break;
}
if (!FLAG_PATTERN.test(arg)) {
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 guard correctly skips flag-looking strings (so --unknown isn't collected as a positional), but it also means unknown flags are silently swallowed. For typegen this is fine — the only expected positional is the directory — but as more commands adopt positionals, a warning for unrecognized flags would improve DX.

Not blocking for this PR.

);
}

async function drainAppRouteTypeGeneration() {
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 drain loop design is clean. One subtle detail worth calling out: the while loop clears pending at the top, then awaits the write. If an event arrives during the write, pending gets set to true by regenerateAppRouteTypes, and the loop runs another iteration — which is correct.

The finally block at line 2095 covers the edge case where an event arrives after the loop exits but before finally runs (i.e. during microtask scheduling of the .finally callback). Since both the event handler and finally run on the same microtask queue, this window is extremely narrow, but the guard is still correct to have. Good attention to detail.

}
if (hasAppDir && shouldInvalidateAppRouteFile(appDir, filePath, fileMatcher)) {
invalidateAppRoutingModules();
regenerateAppRouteTypes();
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.

Typegen regeneration hooks on add and unlink but not change. This matches the existing invalidateAppRoutingModules pattern, so it's consistent.

However, if a user renames page.tsx to route.ts in the same directory (editor "save as" rather than move), the watcher fires unlink for the old file + add for the new one, so that case is covered. The gap is an in-place edit that changes exports (e.g. adding/removing generateStaticParams) — but since typegen only cares about route presence, not exports, this is fine.

Just documenting the reasoning for future readers.


for (const slot of route.parallelSlots) {
const ownerLayoutId = findSlotOwnerLayoutId(route, slot);
if (slot.layoutPath) {
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 463-467 is well-written and explains a non-obvious invariant. This is exactly the kind of documentation that prevents future confusion.

One edge case to consider for a follow-up: if a slot directory has a layout.tsx and nested sub-routes with their own slots, those nested slots would have ownerLayoutId pointing to the slot-local layout. The current code handles this correctly because the materialized slot-local layout gets its own entry in layouts, so layoutRouteKeyForSlot in typegen can resolve it. Just confirming the chain works end-to-end.

return params;
}

function createLayoutRouteKeyMap(layouts: Iterable<{ treePath: string }>): Map<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.

Nice design. createLayoutRouteKeyMap does a two-pass approach: first group tree paths by their URL route literal, then disambiguate collisions by falling back to the scoped variant. This cleanly separates the common case (no collision, use URL route) from the edge case (route-group collision, use scoped key).

The treePaths.length === 1 check at line 269 ensures disambiguation only fires when necessary, avoiding unnecessarily noisy route keys like "/(marketing)" when there's no ambiguity.

Comment thread tests/typegen.test.ts
});
});

it("keeps layout slots scoped to their root route group", 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.

This test is a good regression guard for the route-group slot scoping bug that Codex caught. The negative assertion at line 100 (not.toContain) is important — it verifies that slot names from different route groups don't leak into a merged "/" entry.

One gap: there's no test for a project with both (marketing) and (shop) route groups plus a root layout at app/layout.tsx. In that case, three layouts collapse to "/" and all three need disambiguation. If this is supported, it'd be worth adding coverage. If it's an error in Next.js (which I believe it is — multiple root layouts conflict), then the current test is correct.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 12, 2026

Review posted successfully. Here's a summary of what I found:

Overall assessment: This is solid, well-iterated work. The type model design is clean, layout disambiguation is correct, the serialized dev regeneration handles rapid watcher bursts properly, and test coverage is thorough across all the important edge cases.

Key observations:

  1. params map last-write-winsaddRoute unconditionally overwrites param shapes, so if a page and layout produce the same route string, the layout's shape silently wins. Not a real concern today (shapes should always agree), but the contract is implicit.

  2. next-env.d.ts references next types — Acknowledged deferral is reasonable. Suggested the follow-up could conditionally reference vinext types when next isn't resolvable.

  3. Unknown CLI flags silently dropped — Pre-existing behavior, but worth noting since typegen is the first command relying on positionals.

  4. No change watcher event — Regeneration hooks on add and unlink only, matching the existing invalidateAppRoutingModules pattern. Consistent but worth documenting.

  5. Drain loop design — Verified the serialization pattern is correct, including the finally re-check edge case.

  6. Slot-local layout materialization — Confirmed convertTreePathToRouteParts correctly handles @slot segments (skipped as invisible), and the slot ownership chain works end-to-end.

No blocking issues found.

github run

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.

Implement native Vinext route-aware type generation

2 participants