From ff0c7bf72cd7ec4999a3bcfa3f4f848a5f26aacd Mon Sep 17 00:00:00 2001 From: JamesbbBriz Date: Sat, 9 May 2026 08:43:09 +0800 Subject: [PATCH 01/18] Add native App Router route typegen --- packages/vinext/src/cli.ts | 44 ++++++ packages/vinext/src/index.ts | 23 +++ packages/vinext/src/typegen.ts | 280 +++++++++++++++++++++++++++++++++ tests/typegen.test.ts | 113 +++++++++++++ 4 files changed, 460 insertions(+) create mode 100644 packages/vinext/src/typegen.ts create mode 100644 tests/typegen.test.ts diff --git a/packages/vinext/src/cli.ts b/packages/vinext/src/cli.ts index e64efb2a7..a59d1d197 100644 --- a/packages/vinext/src/cli.ts +++ b/packages/vinext/src/cli.ts @@ -7,6 +7,7 @@ * vinext build Build for production * vinext start Start production server * vinext deploy Deploy to Cloudflare Workers + * vinext typegen Generate App Router route helper types * vinext lint Run linter (delegates to eslint/oxlint) * * Automatically configures Vite with the vinext plugin — no vite.config.ts @@ -35,6 +36,7 @@ import { formatAlreadyRunningError, tryAcquireLockfile, } from "./server/dev-lockfile.js"; +import { generateRouteTypes } from "./typegen.js"; // ─── Resolve Vite from the project root ──────────────────────────────────────── // @@ -721,6 +723,23 @@ async function check() { console.log(formatReport(result)); } +async function typegen() { + const parsed = parseArgs(rawArgs); + if (parsed.help) return printHelp("typegen"); + + const root = process.cwd(); + loadDotenv({ + root, + mode: "development", + }); + const resolvedNextConfig = await resolveNextConfig(await loadNextConfig(root), root); + const outputPath = await generateRouteTypes({ + root, + pageExtensions: resolvedNextConfig.pageExtensions, + }); + console.log(`\n Generated route types at ${path.relative(root, outputPath)}\n`); +} + async function initCommand() { const parsed = parseArgs(rawArgs); if (parsed.help) return printHelp("init"); @@ -889,6 +908,22 @@ function printHelp(cmd?: string) { return; } + if (cmd === "typegen") { + console.log(` + vinext typegen - Generate App Router route helper types + + Usage: vinext typegen [options] + + Generates Next-compatible global route helpers for App Router projects: + PageProps, LayoutProps, and RouteContext. Output is written to + .next/types/routes.d.ts. + + Options: + -h, --help Show this help +`); + return; + } + if (cmd === "lint") { console.log(` vinext lint - Run linter @@ -914,6 +949,7 @@ function printHelp(cmd?: string) { build Build for production start Start production server deploy Deploy to Cloudflare Workers + typegen Generate App Router route helper types init Migrate a Next.js project to vinext check Scan Next.js app for compatibility lint Run linter @@ -926,6 +962,7 @@ function printHelp(cmd?: string) { vinext dev Start dev server on port 3000 vinext dev -p 4000 Start dev server on port 4000 vinext build Build for production + vinext typegen Generate route helper types vinext start Start production server vinext deploy Deploy to Cloudflare Workers vinext init Migrate a Next.js project @@ -992,6 +1029,13 @@ switch (command) { }); break; + case "typegen": + typegen().catch((e) => { + console.error(e); + process.exit(1); + }); + break; + case "lint": lint().catch((e) => { console.error(e); diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 74f70c693..104ff68ef 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -85,6 +85,7 @@ import { createRscClientReferenceLoadersPlugin } from "./plugins/rsc-client-refe import { createInstrumentationClientTransformPlugin } from "./plugins/instrumentation-client.js"; import { createOptimizeImportsPlugin } from "./plugins/optimize-imports.js"; import { createOgInlineFetchAssetsPlugin, ogAssetsPlugin } from "./plugins/og-assets.js"; +import { generateRouteTypes } from "./typegen.js"; import { mergeOptimizeDepsExclude, SSR_EXTERNAL_REACT_ENTRIES, @@ -627,6 +628,15 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { return _generateClientEntry(pagesDir, nextConfig, fileMatcher); } + async function writeRouteTypes(): Promise { + if (!hasAppDir) return; + await generateRouteTypes({ + root, + appDir, + pageExtensions: nextConfig.pageExtensions, + }); + } + // Auto-register @vitejs/plugin-rsc when App Router is detected. // Check eagerly at call time using the same heuristic as config(). // Must mirror the full detection logic: check {base}/app then {base}/src/app. @@ -936,6 +946,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { instrumentationPath = findInstrumentationFile(root, fileMatcher); instrumentationClientPath = findInstrumentationClientFile(root, fileMatcher); middlewarePath = findMiddlewareFile(root, fileMatcher); + await writeRouteTypes(); // Merge env from next.config.js with NEXT_PUBLIC_* env vars const defines = getNextPublicEnvDefines(); @@ -2177,6 +2188,16 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { invalidateRootParamsModule(); } + function regenerateAppRouteTypes() { + writeRouteTypes().catch((error: unknown) => { + server.config.logger.warn( + `[vinext] Failed to regenerate route types: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + }); + } + // Node throws on unhandled 'error' events on sockets. When a browser // drops the connection mid-response (common in dev: HMR triggers a // reload while an RSC stream is still flushing), the next res.write @@ -2195,6 +2216,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } if (hasAppDir && shouldInvalidateAppRouteFile(appDir, filePath, fileMatcher)) { invalidateAppRoutingModules(); + regenerateAppRouteTypes(); } }); server.watcher.on("unlink", (filePath: string) => { @@ -2203,6 +2225,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } if (hasAppDir && shouldInvalidateAppRouteFile(appDir, filePath, fileMatcher)) { invalidateAppRoutingModules(); + regenerateAppRouteTypes(); } }); diff --git a/packages/vinext/src/typegen.ts b/packages/vinext/src/typegen.ts new file mode 100644 index 000000000..144e1c161 --- /dev/null +++ b/packages/vinext/src/typegen.ts @@ -0,0 +1,280 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { appRouteGraph } from "./routing/app-router.js"; +import { patternToNextFormat } from "./routing/route-validation.js"; + +type GenerateRouteTypesOptions = { + root: string; + appDir?: string | null; + pageExtensions?: readonly string[]; +}; + +type ParamShape = Map; + +export async function generateRouteTypes(options: GenerateRouteTypesOptions): Promise { + const root = path.resolve(options.root); + const appDir = options.appDir ? path.resolve(options.appDir) : await findAppDir(root); + const outPath = path.join(root, ".next", "types", "routes.d.ts"); + + const content = appDir + ? renderRouteTypes(await collectRouteTypeModel(appDir, options.pageExtensions)) + : renderRouteTypes(emptyRouteTypeModel()); + + await fs.mkdir(path.dirname(outPath), { recursive: true }); + await fs.writeFile(outPath, content, "utf-8"); + return outPath; +} + +type RouteTypeModel = { + pageRoutes: string[]; + layoutRoutes: string[]; + routeHandlerRoutes: string[]; + params: Map; + layoutSlots: Map; +}; + +function emptyRouteTypeModel(): RouteTypeModel { + return { + pageRoutes: [], + layoutRoutes: [], + routeHandlerRoutes: [], + params: new Map(), + layoutSlots: new Map(), + }; +} + +async function collectRouteTypeModel( + appDir: string, + pageExtensions?: readonly string[], +): Promise { + const graph = await appRouteGraph(appDir, pageExtensions); + const model = emptyRouteTypeModel(); + const segmentGraph = graph.routeManifest.segmentGraph; + + for (const route of segmentGraph.pages.values()) { + const routeEntry = segmentGraph.routes.get(route.routeId); + addRoute( + model.pageRoutes, + model.params, + patternToNextFormat(route.pattern), + paramsForPatternParts(routeEntry?.patternParts ?? []), + ); + } + + for (const route of segmentGraph.routeHandlers.values()) { + const routeEntry = segmentGraph.routes.get(route.routeId); + addRoute( + model.routeHandlerRoutes, + model.params, + patternToNextFormat(route.pattern), + paramsForPatternParts(routeEntry?.patternParts ?? []), + ); + } + + for (const layout of segmentGraph.layouts.values()) { + const route = treePathToRouteLiteral(layout.treePath); + addRoute(model.layoutRoutes, model.params, route, paramsForRouteLiteral(route)); + } + + for (const slot of segmentGraph.slots.values()) { + const layoutRoute = treePathToRouteLiteral(slot.ownerTreePath); + const slots = model.layoutSlots.get(layoutRoute) ?? []; + if (!slots.includes(slot.name)) { + slots.push(slot.name); + slots.sort(compareStrings); + } + model.layoutSlots.set(layoutRoute, slots); + } + + return model; +} + +async function findAppDir(root: string): Promise { + for (const rel of ["app", path.join("src", "app")]) { + const candidate = path.join(root, rel); + try { + const stat = await fs.stat(candidate); + if (stat.isDirectory()) return candidate; + } catch { + // Try the next conventional app directory. + } + } + return null; +} + +function renderRouteTypes(model: RouteTypeModel): string { + const allRoutes = uniqueSorted([ + ...model.pageRoutes, + ...model.layoutRoutes, + ...model.routeHandlerRoutes, + ]); + + return `// This file is generated by vinext. Do not edit. +import type * as React from "react"; + +declare global { + type PageProps = { + params: Promise; + searchParams: Promise>; + }; + + type LayoutProps = { + params: Promise; + children: React.ReactNode; + } & { + [K in VinextRouteTypes.LayoutSlotMap[Route]]: React.ReactNode; + }; + + type RouteContext = { + params: Promise; + }; +} + +declare namespace VinextRouteTypes { + type PageRoute = ${routeUnion(model.pageRoutes)}; + type LayoutRoute = ${routeUnion(model.layoutRoutes)}; + type RouteHandlerRoute = ${routeUnion(model.routeHandlerRoutes)}; + type AppRoute = ${routeUnion(allRoutes)}; + + interface ParamMap { +${renderParamMap(allRoutes, model.params)} + } + + interface LayoutSlotMap { +${renderLayoutSlotMap(model.layoutRoutes, model.layoutSlots)} + } +} + +export {}; +`; +} + +function renderParamMap( + routes: readonly string[], + params: ReadonlyMap, +): string { + if (routes.length === 0) return " [route: string]: {};\n"; + + return routes + .map((route) => ` ${quote(route)}: ${renderParamShape(params.get(route) ?? new Map())};`) + .join("\n"); +} + +function renderParamShape(params: ParamShape): string { + if (params.size === 0) return "{}"; + + const fields = Array.from(params.entries()) + .sort(([left], [right]) => compareStrings(left, right)) + .map(([name, kind]) => { + const optional = kind === "string[]?"; + const valueType = optional ? "string[]" : kind; + return `${propertyName(name)}${optional ? "?" : ""}: ${valueType};`; + }); + + return `{ ${fields.join(" ")} }`; +} + +function renderLayoutSlotMap( + layoutRoutes: readonly string[], + layoutSlots: ReadonlyMap, +): string { + if (layoutRoutes.length === 0) return " [route: string]: never;\n"; + + return layoutRoutes + .map((route) => { + const slots = layoutSlots.get(route) ?? []; + return ` ${quote(route)}: ${routeUnion(slots)};`; + }) + .join("\n"); +} + +function paramsForRouteLiteral(route: string): ParamShape { + const params: ParamShape = new Map(); + for (const segment of route.split("/")) { + const optionalCatchAll = segment.match(/^\[\[\.\.\.([^\]]+)\]\]$/); + if (optionalCatchAll) { + params.set(optionalCatchAll[1], "string[]?"); + continue; + } + + const catchAll = segment.match(/^\[\.\.\.([^\]]+)\]$/); + if (catchAll) { + params.set(catchAll[1], "string[]"); + continue; + } + + const dynamic = segment.match(/^\[([^\]]+)\]$/); + if (dynamic) { + params.set(dynamic[1], "string"); + } + } + return params; +} + +function paramsForPatternParts(patternParts: readonly string[]): ParamShape { + const params: ParamShape = new Map(); + for (const part of patternParts) { + if (!part.startsWith(":")) continue; + + if (part.endsWith("+")) { + params.set(part.slice(1, -1), "string[]"); + } else if (part.endsWith("*")) { + params.set(part.slice(1, -1), "string[]?"); + } else { + params.set(part.slice(1), "string"); + } + } + return params; +} + +function treePathToRouteLiteral(treePath: string): string { + if (treePath === "/") return "/"; + + const segments = treePath + .split("/") + .filter(Boolean) + .filter((segment) => !isInvisibleSegment(segment)); + return segments.length === 0 ? "/" : `/${segments.join("/")}`; +} + +function isInvisibleSegment(segment: string): boolean { + return ( + segment === "." || (segment.startsWith("(") && segment.endsWith(")")) || segment.startsWith("@") + ); +} + +function addRoute( + routes: string[], + params: Map, + route: string, + paramShape: ParamShape, +): void { + if (!routes.includes(route)) { + routes.push(route); + routes.sort(compareStrings); + } + params.set(route, paramShape); +} + +function uniqueSorted(values: readonly string[]): string[] { + return Array.from(new Set(values)).sort(compareStrings); +} + +function routeUnion(routes: readonly string[]): string { + if (routes.length === 0) return "never"; + return routes.map(quote).join(" | "); +} + +function propertyName(name: string): string { + return /^[A-Za-z_$][\w$]*$/.test(name) ? name : quote(name); +} + +function quote(value: string): string { + return JSON.stringify(value); +} + +function compareStrings(left: string, right: string): number { + if (left < right) return -1; + if (left > right) return 1; + return 0; +} diff --git a/tests/typegen.test.ts b/tests/typegen.test.ts new file mode 100644 index 000000000..4551d4095 --- /dev/null +++ b/tests/typegen.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect } from "vite-plus/test"; +import { createServer, type ViteDevServer } from "vite-plus"; +import os from "node:os"; +import path from "node:path"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import vinext from "../packages/vinext/src/index.js"; +import { generateRouteTypes } from "../packages/vinext/src/typegen.js"; + +const EMPTY_PAGE = "export default function Page() { return null; }\n"; +const EMPTY_LAYOUT = "export default function Layout({ children }: any) { return children; }\n"; +const EMPTY_ROUTE = "export async function GET() { return Response.json({ ok: true }); }\n"; + +async function withTempProject(run: (root: string) => Promise): Promise { + const root = await mkdtemp(path.join(os.tmpdir(), "vinext-typegen-")); + try { + return await run(root); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +async function writeProjectFile(root: string, relPath: string, content: string): Promise { + const fullPath = path.join(root, relPath); + await mkdir(path.dirname(fullPath), { recursive: true }); + await writeFile(fullPath, content); +} + +async function eventually(run: () => Promise, timeoutMs = 3_000): Promise { + const start = Date.now(); + let lastError: unknown; + while (Date.now() - start < timeoutMs) { + try { + await run(); + return; + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + } + throw lastError; +} + +describe("generateRouteTypes", () => { + it("generates Next-compatible global route helper types from the App Router tree", async () => { + await withTempProject(async (root) => { + await writeProjectFile(root, "app/layout.tsx", EMPTY_LAYOUT); + await writeProjectFile(root, "app/page.tsx", EMPTY_PAGE); + await writeProjectFile(root, "app/blog/[slug]/page.tsx", EMPTY_PAGE); + await writeProjectFile(root, "app/docs/[...slug]/page.tsx", EMPTY_PAGE); + await writeProjectFile(root, "app/shop/[[...slug]]/page.tsx", EMPTY_PAGE); + await writeProjectFile(root, "app/api/items/[id]/route.ts", EMPTY_ROUTE); + await writeProjectFile(root, "app/dashboard/layout.tsx", EMPTY_LAYOUT); + await writeProjectFile(root, "app/dashboard/page.tsx", EMPTY_PAGE); + await writeProjectFile(root, "app/dashboard/@analytics/default.tsx", EMPTY_PAGE); + + const outputPath = await generateRouteTypes({ root }); + const generated = await readFile(outputPath, "utf-8"); + + expect(outputPath).toBe(path.join(root, ".next/types/routes.d.ts")); + expect(generated).toContain("declare namespace VinextRouteTypes"); + expect(generated).toContain( + 'type PageRoute = "/" | "/blog/[slug]" | "/dashboard" | "/docs/[...slug]" | "/shop/[[...slug]]";', + ); + expect(generated).toContain('type RouteHandlerRoute = "/api/items/[id]";'); + expect(generated).toContain('"/blog/[slug]": { slug: string; };'); + expect(generated).toContain('"/docs/[...slug]": { slug: string[]; };'); + expect(generated).toContain('"/shop/[[...slug]]": { slug?: string[]; };'); + expect(generated).toContain('"/dashboard": "analytics";'); + expect(generated).toContain( + "type PageProps", + ); + expect(generated).toContain( + "type LayoutProps", + ); + expect(generated).toContain( + "type RouteContext", + ); + }); + }); + + it("updates generated route helper types when App Router files are added in dev", async () => { + await withTempProject(async (root) => { + await writeProjectFile(root, "app/layout.tsx", EMPTY_LAYOUT); + await writeProjectFile(root, "app/page.tsx", EMPTY_PAGE); + + let server: ViteDevServer | null = null; + try { + server = await createServer({ + root, + logLevel: "silent", + plugins: [vinext({ appDir: root })], + }); + + const generatedPath = path.join(root, ".next", "types", "routes.d.ts"); + await eventually(async () => { + expect(await readFile(generatedPath, "utf-8")).toContain('type PageRoute = "/";'); + }); + + const aboutPage = path.join(root, "app/about/page.tsx"); + await writeProjectFile(root, "app/about/page.tsx", EMPTY_PAGE); + server.watcher.emit("add", aboutPage); + + await eventually(async () => { + expect(await readFile(generatedPath, "utf-8")).toContain( + 'type PageRoute = "/" | "/about";', + ); + }); + } finally { + await server?.close(); + } + }); + }); +}); From b6e5246137547337e32ed90b3d1d4e62c92b28e8 Mon Sep 17 00:00:00 2001 From: JamesbbBriz Date: Sat, 9 May 2026 09:27:53 +0800 Subject: [PATCH 02/18] Address route typegen review feedback --- packages/vinext/src/cli.ts | 7 +++++-- packages/vinext/src/typegen.ts | 4 +++- tests/typegen.test.ts | 5 ++++- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/vinext/src/cli.ts b/packages/vinext/src/cli.ts index a59d1d197..7c54b69e1 100644 --- a/packages/vinext/src/cli.ts +++ b/packages/vinext/src/cli.ts @@ -730,9 +730,12 @@ async function typegen() { const root = process.cwd(); loadDotenv({ root, - mode: "development", + mode: "production", }); - const resolvedNextConfig = await resolveNextConfig(await loadNextConfig(root), root); + const resolvedNextConfig = await resolveNextConfig( + await loadNextConfig(root, PHASE_PRODUCTION_BUILD), + root, + ); const outputPath = await generateRouteTypes({ root, pageExtensions: resolvedNextConfig.pageExtensions, diff --git a/packages/vinext/src/typegen.ts b/packages/vinext/src/typegen.ts index 144e1c161..7d14bf542 100644 --- a/packages/vinext/src/typegen.ts +++ b/packages/vinext/src/typegen.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { appRouteGraph } from "./routing/app-router.js"; import { patternToNextFormat } from "./routing/route-validation.js"; +import { decodeRouteSegment } from "./routing/utils.js"; type GenerateRouteTypesOptions = { root: string; @@ -233,7 +234,8 @@ function treePathToRouteLiteral(treePath: string): string { const segments = treePath .split("/") .filter(Boolean) - .filter((segment) => !isInvisibleSegment(segment)); + .filter((segment) => !isInvisibleSegment(segment)) + .map((segment) => decodeRouteSegment(segment)); return segments.length === 0 ? "/" : `/${segments.join("/")}`; } diff --git a/tests/typegen.test.ts b/tests/typegen.test.ts index 4551d4095..29e71026b 100644 --- a/tests/typegen.test.ts +++ b/tests/typegen.test.ts @@ -52,6 +52,8 @@ describe("generateRouteTypes", () => { await writeProjectFile(root, "app/dashboard/layout.tsx", EMPTY_LAYOUT); await writeProjectFile(root, "app/dashboard/page.tsx", EMPTY_PAGE); await writeProjectFile(root, "app/dashboard/@analytics/default.tsx", EMPTY_PAGE); + await writeProjectFile(root, "app/%5Fsites/layout.tsx", EMPTY_LAYOUT); + await writeProjectFile(root, "app/%5Fsites/page.tsx", EMPTY_PAGE); const outputPath = await generateRouteTypes({ root }); const generated = await readFile(outputPath, "utf-8"); @@ -59,8 +61,9 @@ describe("generateRouteTypes", () => { expect(outputPath).toBe(path.join(root, ".next/types/routes.d.ts")); expect(generated).toContain("declare namespace VinextRouteTypes"); expect(generated).toContain( - 'type PageRoute = "/" | "/blog/[slug]" | "/dashboard" | "/docs/[...slug]" | "/shop/[[...slug]]";', + 'type PageRoute = "/" | "/_sites" | "/blog/[slug]" | "/dashboard" | "/docs/[...slug]" | "/shop/[[...slug]]";', ); + expect(generated).toContain('type LayoutRoute = "/" | "/_sites" | "/dashboard";'); expect(generated).toContain('type RouteHandlerRoute = "/api/items/[id]";'); expect(generated).toContain('"/blog/[slug]": { slug: string; };'); expect(generated).toContain('"/docs/[...slug]": { slug: string[]; };'); From 9f9fc5811e6f46e424cfbfac07ba0145f1deabf0 Mon Sep 17 00:00:00 2001 From: JamesbbBriz Date: Sat, 9 May 2026 17:49:02 +0800 Subject: [PATCH 03/18] Respect typegen target directory --- packages/vinext/src/cli-args.ts | 9 +++++++++ packages/vinext/src/cli.ts | 6 +++--- tests/cli-args.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/vinext/src/cli-args.ts b/packages/vinext/src/cli-args.ts index b9c88c103..7759d2907 100644 --- a/packages/vinext/src/cli-args.ts +++ b/packages/vinext/src/cli-args.ts @@ -16,6 +16,7 @@ type ParsedArgs = { prerenderAll?: boolean; prerenderConcurrency?: number; precompress?: boolean; + positionals?: string[]; }; // Matches long flags (--foo) and single-letter short flags (-x). @@ -92,6 +93,11 @@ export function parsePositiveIntegerArg(raw: string, flag: string): number { */ export function parseArgs(args: string[]): ParsedArgs { const result: ParsedArgs = {}; + const addPositional = (arg: string): void => { + result.positionals ??= []; + result.positionals.push(arg); + }; + for (let i = 0; i < args.length; i++) { const arg = args[i]; @@ -166,6 +172,9 @@ export function parseArgs(args: string[]): ParsedArgs { ); break; } + if (!FLAG_PATTERN.test(arg)) { + addPositional(arg); + } break; } } diff --git a/packages/vinext/src/cli.ts b/packages/vinext/src/cli.ts index 7c54b69e1..8f396d4fe 100644 --- a/packages/vinext/src/cli.ts +++ b/packages/vinext/src/cli.ts @@ -727,7 +727,7 @@ async function typegen() { const parsed = parseArgs(rawArgs); if (parsed.help) return printHelp("typegen"); - const root = process.cwd(); + const root = path.resolve(parsed.positionals?.[0] ?? process.cwd()); loadDotenv({ root, mode: "production", @@ -915,11 +915,11 @@ function printHelp(cmd?: string) { console.log(` vinext typegen - Generate App Router route helper types - Usage: vinext typegen [options] + Usage: vinext typegen [directory] [options] Generates Next-compatible global route helpers for App Router projects: PageProps, LayoutProps, and RouteContext. Output is written to - .next/types/routes.d.ts. + .next/types/routes.d.ts under the target directory. Options: -h, --help Show this help diff --git a/tests/cli-args.test.ts b/tests/cli-args.test.ts index 7b5b8682f..c6666c371 100644 --- a/tests/cli-args.test.ts +++ b/tests/cli-args.test.ts @@ -279,6 +279,28 @@ describe("combined flags", () => { }); }); +// ─── Positional arguments ─────────────────────────────────────────────────── + +describe("positional arguments", () => { + it("keeps positional arguments for commands with directory targets", () => { + expect(parseArgs(["apps/web"])).toMatchObject({ positionals: ["apps/web"] }); + }); + + it("keeps positionals alongside flags", () => { + expect(parseArgs(["apps/web", "--verbose"])).toMatchObject({ + positionals: ["apps/web"], + verbose: true, + }); + }); + + it("does not treat values consumed by flags as positional arguments", () => { + expect(parseArgs(["--port", "4000", "apps/web"])).toMatchObject({ + port: 4000, + positionals: ["apps/web"], + }); + }); +}); + // ─── Edge cases ───────────────────────────────────────────────────────────── describe("edge cases", () => { From f4300b40836d3fe4b2944ee6311d88927a7af4f1 Mon Sep 17 00:00:00 2001 From: JamesbbBriz Date: Sun, 10 May 2026 02:30:10 +0800 Subject: [PATCH 04/18] Require explicit LayoutProps route --- packages/vinext/src/typegen.ts | 2 +- tests/typegen.test.ts | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/vinext/src/typegen.ts b/packages/vinext/src/typegen.ts index 7d14bf542..b195aa765 100644 --- a/packages/vinext/src/typegen.ts +++ b/packages/vinext/src/typegen.ts @@ -119,7 +119,7 @@ declare global { searchParams: Promise>; }; - type LayoutProps = { + type LayoutProps = { params: Promise; children: React.ReactNode; } & { diff --git a/tests/typegen.test.ts b/tests/typegen.test.ts index 29e71026b..08c9c0dc1 100644 --- a/tests/typegen.test.ts +++ b/tests/typegen.test.ts @@ -72,9 +72,7 @@ describe("generateRouteTypes", () => { expect(generated).toContain( "type PageProps", ); - expect(generated).toContain( - "type LayoutProps", - ); + expect(generated).toContain("type LayoutProps"); expect(generated).toContain( "type RouteContext", ); From 4b45538687c0b0e44f64817ef11462dbb57040bf Mon Sep 17 00:00:00 2001 From: JamesbbBriz Date: Sun, 10 May 2026 02:59:30 +0800 Subject: [PATCH 05/18] Serialize route type regeneration --- packages/vinext/src/index.ts | 34 ++++++++++++++++++++++++++++------ tests/typegen.test.ts | 13 +++++++++++++ 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 104ff68ef..c2880f08e 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -2188,13 +2188,35 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { invalidateRootParamsModule(); } + let appRouteTypeGeneration: Promise | null = null; + let appRouteTypeGenerationPending = false; + + function warnRouteTypeGenerationFailure(error: unknown) { + server.config.logger.warn( + `[vinext] Failed to regenerate route types: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + + async function drainAppRouteTypeGeneration() { + while (appRouteTypeGenerationPending) { + appRouteTypeGenerationPending = false; + try { + await writeRouteTypes(); + } catch (error) { + warnRouteTypeGenerationFailure(error); + } + } + } + function regenerateAppRouteTypes() { - writeRouteTypes().catch((error: unknown) => { - server.config.logger.warn( - `[vinext] Failed to regenerate route types: ${ - error instanceof Error ? error.message : String(error) - }`, - ); + appRouteTypeGenerationPending = true; + if (appRouteTypeGeneration) return; + + appRouteTypeGeneration = drainAppRouteTypeGeneration().finally(() => { + appRouteTypeGeneration = null; + if (appRouteTypeGenerationPending) regenerateAppRouteTypes(); }); } diff --git a/tests/typegen.test.ts b/tests/typegen.test.ts index 08c9c0dc1..1f52b4ac6 100644 --- a/tests/typegen.test.ts +++ b/tests/typegen.test.ts @@ -106,6 +106,19 @@ describe("generateRouteTypes", () => { 'type PageRoute = "/" | "/about";', ); }); + + const blogPage = path.join(root, "app/blog/page.tsx"); + const docsPage = path.join(root, "app/docs/page.tsx"); + await writeProjectFile(root, "app/blog/page.tsx", EMPTY_PAGE); + await writeProjectFile(root, "app/docs/page.tsx", EMPTY_PAGE); + server.watcher.emit("add", blogPage); + server.watcher.emit("add", docsPage); + + await eventually(async () => { + expect(await readFile(generatedPath, "utf-8")).toContain( + 'type PageRoute = "/" | "/about" | "/blog" | "/docs";', + ); + }); } finally { await server?.close(); } From 1f3ea02123340dba45e572de3dd5148b1d8a7454 Mon Sep 17 00:00:00 2001 From: JamesbbBriz Date: Sun, 10 May 2026 04:20:23 +0800 Subject: [PATCH 06/18] Derive layout params from route graph --- .../vinext/src/routing/app-route-graph.ts | 18 +++++++++++++ packages/vinext/src/typegen.ts | 25 +------------------ tests/app-route-graph.test.ts | 2 ++ tests/typegen.test.ts | 7 ++++-- 4 files changed, 26 insertions(+), 26 deletions(-) diff --git a/packages/vinext/src/routing/app-route-graph.ts b/packages/vinext/src/routing/app-route-graph.ts index 78786821d..aea1a033e 100644 --- a/packages/vinext/src/routing/app-route-graph.ts +++ b/packages/vinext/src/routing/app-route-graph.ts @@ -213,6 +213,8 @@ export type RouteManifestRouteHandler = { export type RouteManifestLayout = { id: string; treePath: string; + patternParts: readonly string[]; + paramNames: readonly string[]; rootBoundaryId: RootBoundaryId | null; }; @@ -401,9 +403,12 @@ function createStaticSegmentGraph(routes: readonly AppRouteGraphRoute[]): Static if (existingLayout) { assertRouteManifestRootBoundary("layout", route, layoutId, existingLayout.rootBoundaryId); } + const layoutRouteParts = convertTreePathToRouteParts(treePath); const layout = { id: layoutId, treePath, + patternParts: layoutRouteParts.urlSegments, + paramNames: layoutRouteParts.params, rootBoundaryId: route.ids.rootBoundary, }; layouts.set(layoutId, layout); @@ -1237,6 +1242,19 @@ function createAppRouteGraphTreePath( return `/${treePathSegments.join("/")}`; } +function convertTreePathToRouteParts(treePath: string): { + urlSegments: string[]; + params: string[]; +} { + if (treePath === "/") return { urlSegments: [], params: [] }; + const segments = treePath.split("/").filter(Boolean); + const routeParts = convertSegmentsToRouteParts(segments); + if (!routeParts) { + throw new Error(`Invalid App Router layout tree path "${treePath}".`); + } + return { urlSegments: routeParts.urlSegments, params: routeParts.params }; +} + /** * Compute the tree position (directory depth from app root) for each layout. * Root layout = 0, a layout at app/blog/ = 1, app/blog/(group)/ = 2. diff --git a/packages/vinext/src/typegen.ts b/packages/vinext/src/typegen.ts index b195aa765..e0f0945d4 100644 --- a/packages/vinext/src/typegen.ts +++ b/packages/vinext/src/typegen.ts @@ -74,7 +74,7 @@ async function collectRouteTypeModel( for (const layout of segmentGraph.layouts.values()) { const route = treePathToRouteLiteral(layout.treePath); - addRoute(model.layoutRoutes, model.params, route, paramsForRouteLiteral(route)); + addRoute(model.layoutRoutes, model.params, route, paramsForPatternParts(layout.patternParts)); } for (const slot of segmentGraph.slots.values()) { @@ -189,29 +189,6 @@ function renderLayoutSlotMap( .join("\n"); } -function paramsForRouteLiteral(route: string): ParamShape { - const params: ParamShape = new Map(); - for (const segment of route.split("/")) { - const optionalCatchAll = segment.match(/^\[\[\.\.\.([^\]]+)\]\]$/); - if (optionalCatchAll) { - params.set(optionalCatchAll[1], "string[]?"); - continue; - } - - const catchAll = segment.match(/^\[\.\.\.([^\]]+)\]$/); - if (catchAll) { - params.set(catchAll[1], "string[]"); - continue; - } - - const dynamic = segment.match(/^\[([^\]]+)\]$/); - if (dynamic) { - params.set(dynamic[1], "string"); - } - } - return params; -} - function paramsForPatternParts(patternParts: readonly string[]): ParamShape { const params: ParamShape = new Map(); for (const part of patternParts) { diff --git a/tests/app-route-graph.test.ts b/tests/app-route-graph.test.ts index 75a133c18..cb7ad3e6c 100644 --- a/tests/app-route-graph.test.ts +++ b/tests/app-route-graph.test.ts @@ -365,6 +365,8 @@ describe("App Router route graph builder", () => { expect(segmentGraph.layouts.get("layout:/(marketing)/blog/[slug]")).toEqual({ id: "layout:/(marketing)/blog/[slug]", treePath: "/(marketing)/blog/[slug]", + patternParts: ["blog", ":slug"], + paramNames: ["slug"], rootBoundaryId: "root-boundary:/", }); expect(segmentGraph.templates.get("template:/(marketing)/blog/[slug]")).toEqual({ diff --git a/tests/typegen.test.ts b/tests/typegen.test.ts index 1f52b4ac6..1063ab5d6 100644 --- a/tests/typegen.test.ts +++ b/tests/typegen.test.ts @@ -54,6 +54,8 @@ describe("generateRouteTypes", () => { await writeProjectFile(root, "app/dashboard/@analytics/default.tsx", EMPTY_PAGE); await writeProjectFile(root, "app/%5Fsites/layout.tsx", EMPTY_LAYOUT); await writeProjectFile(root, "app/%5Fsites/page.tsx", EMPTY_PAGE); + await writeProjectFile(root, "app/%5Bslug%5D/layout.tsx", EMPTY_LAYOUT); + await writeProjectFile(root, "app/%5Bslug%5D/page.tsx", EMPTY_PAGE); const outputPath = await generateRouteTypes({ root }); const generated = await readFile(outputPath, "utf-8"); @@ -61,10 +63,11 @@ describe("generateRouteTypes", () => { expect(outputPath).toBe(path.join(root, ".next/types/routes.d.ts")); expect(generated).toContain("declare namespace VinextRouteTypes"); expect(generated).toContain( - 'type PageRoute = "/" | "/_sites" | "/blog/[slug]" | "/dashboard" | "/docs/[...slug]" | "/shop/[[...slug]]";', + 'type PageRoute = "/" | "/[slug]" | "/_sites" | "/blog/[slug]" | "/dashboard" | "/docs/[...slug]" | "/shop/[[...slug]]";', ); - expect(generated).toContain('type LayoutRoute = "/" | "/_sites" | "/dashboard";'); + expect(generated).toContain('type LayoutRoute = "/" | "/[slug]" | "/_sites" | "/dashboard";'); expect(generated).toContain('type RouteHandlerRoute = "/api/items/[id]";'); + expect(generated).toContain('"/[slug]": {};'); expect(generated).toContain('"/blog/[slug]": { slug: string; };'); expect(generated).toContain('"/docs/[...slug]": { slug: string[]; };'); expect(generated).toContain('"/shop/[[...slug]]": { slug?: string[]; };'); From d53d7d8e98f66b54984a721267dedb2751f98aff Mon Sep 17 00:00:00 2001 From: JamesbbBriz Date: Sun, 10 May 2026 05:13:00 +0800 Subject: [PATCH 07/18] Scope layout slot typegen by route group --- packages/vinext/src/typegen.ts | 42 ++++++++++++++++++++++++++++++++-- tests/typegen.test.ts | 19 +++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/packages/vinext/src/typegen.ts b/packages/vinext/src/typegen.ts index e0f0945d4..55caac561 100644 --- a/packages/vinext/src/typegen.ts +++ b/packages/vinext/src/typegen.ts @@ -51,6 +51,7 @@ async function collectRouteTypeModel( const graph = await appRouteGraph(appDir, pageExtensions); const model = emptyRouteTypeModel(); const segmentGraph = graph.routeManifest.segmentGraph; + const layoutRouteKeys = createLayoutRouteKeyMap(segmentGraph.layouts.values()); for (const route of segmentGraph.pages.values()) { const routeEntry = segmentGraph.routes.get(route.routeId); @@ -73,12 +74,13 @@ async function collectRouteTypeModel( } for (const layout of segmentGraph.layouts.values()) { - const route = treePathToRouteLiteral(layout.treePath); + const route = layoutRouteKeys.get(layout.treePath) ?? treePathToRouteLiteral(layout.treePath); addRoute(model.layoutRoutes, model.params, route, paramsForPatternParts(layout.patternParts)); } for (const slot of segmentGraph.slots.values()) { - const layoutRoute = treePathToRouteLiteral(slot.ownerTreePath); + const layoutRoute = + layoutRouteKeys.get(slot.ownerTreePath) ?? treePathToRouteLiteral(slot.ownerTreePath); const slots = model.layoutSlots.get(layoutRoute) ?? []; if (!slots.includes(slot.name)) { slots.push(slot.name); @@ -205,6 +207,27 @@ function paramsForPatternParts(patternParts: readonly string[]): ParamShape { return params; } +function createLayoutRouteKeyMap(layouts: Iterable<{ treePath: string }>): Map { + const treePathsByRoute = new Map(); + for (const { treePath } of layouts) { + const route = treePathToRouteLiteral(treePath); + const treePaths = treePathsByRoute.get(route) ?? []; + treePaths.push(treePath); + treePathsByRoute.set(route, treePaths); + } + + const keys = new Map(); + for (const [route, treePaths] of treePathsByRoute) { + for (const treePath of treePaths) { + keys.set( + treePath, + treePaths.length === 1 ? route : treePathToScopedLayoutRouteLiteral(treePath), + ); + } + } + return keys; +} + function treePathToRouteLiteral(treePath: string): string { if (treePath === "/") return "/"; @@ -216,12 +239,27 @@ function treePathToRouteLiteral(treePath: string): string { return segments.length === 0 ? "/" : `/${segments.join("/")}`; } +function treePathToScopedLayoutRouteLiteral(treePath: string): string { + if (treePath === "/") return "/"; + + const segments = treePath + .split("/") + .filter(Boolean) + .filter((segment) => !isSlotSegment(segment) && segment !== ".") + .map((segment) => decodeRouteSegment(segment)); + return segments.length === 0 ? "/" : `/${segments.join("/")}`; +} + function isInvisibleSegment(segment: string): boolean { return ( segment === "." || (segment.startsWith("(") && segment.endsWith(")")) || segment.startsWith("@") ); } +function isSlotSegment(segment: string): boolean { + return segment.startsWith("@"); +} + function addRoute( routes: string[], params: Map, diff --git a/tests/typegen.test.ts b/tests/typegen.test.ts index 1063ab5d6..0f1153b5c 100644 --- a/tests/typegen.test.ts +++ b/tests/typegen.test.ts @@ -82,6 +82,25 @@ describe("generateRouteTypes", () => { }); }); + it("keeps layout slots scoped to their root route group", async () => { + await withTempProject(async (root) => { + await writeProjectFile(root, "app/(marketing)/layout.tsx", EMPTY_LAYOUT); + await writeProjectFile(root, "app/(marketing)/marketing/page.tsx", EMPTY_PAGE); + await writeProjectFile(root, "app/(marketing)/@modal/default.tsx", EMPTY_PAGE); + await writeProjectFile(root, "app/(shop)/layout.tsx", EMPTY_LAYOUT); + await writeProjectFile(root, "app/(shop)/shop/page.tsx", EMPTY_PAGE); + await writeProjectFile(root, "app/(shop)/@cart/default.tsx", EMPTY_PAGE); + + const outputPath = await generateRouteTypes({ root }); + const generated = await readFile(outputPath, "utf-8"); + + expect(generated).toContain('type LayoutRoute = "/(marketing)" | "/(shop)";'); + expect(generated).toContain('"/(marketing)": "modal";'); + expect(generated).toContain('"/(shop)": "cart";'); + expect(generated).not.toContain('"/": "cart" | "modal";'); + }); + }); + it("updates generated route helper types when App Router files are added in dev", async () => { await withTempProject(async (root) => { await writeProjectFile(root, "app/layout.tsx", EMPTY_LAYOUT); From 98f515a657f1ccbba15f4f8b0aa03bef5b1adec9 Mon Sep 17 00:00:00 2001 From: JamesbbBriz Date: Sun, 10 May 2026 06:07:08 +0800 Subject: [PATCH 08/18] Map typegen slots to owner layouts --- packages/vinext/src/typegen.ts | 22 ++++++++++++++++++++-- tests/typegen.test.ts | 15 +++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/packages/vinext/src/typegen.ts b/packages/vinext/src/typegen.ts index 55caac561..2da37f571 100644 --- a/packages/vinext/src/typegen.ts +++ b/packages/vinext/src/typegen.ts @@ -79,8 +79,9 @@ async function collectRouteTypeModel( } for (const slot of segmentGraph.slots.values()) { - const layoutRoute = - layoutRouteKeys.get(slot.ownerTreePath) ?? treePathToRouteLiteral(slot.ownerTreePath); + const layoutRoute = layoutRouteKeyForSlot(slot, segmentGraph.layouts, layoutRouteKeys); + if (!layoutRoute) continue; + const slots = model.layoutSlots.get(layoutRoute) ?? []; if (!slots.includes(slot.name)) { slots.push(slot.name); @@ -228,6 +229,23 @@ function createLayoutRouteKeyMap(layouts: Iterable<{ treePath: string }>): Map, + layoutRouteKeys: ReadonlyMap, +): string | null { + if (!slot.ownerLayoutId) return null; + + const layout = layouts.get(slot.ownerLayoutId); + if (!layout) { + throw new Error( + `[vinext] App route graph invariant violated: slot ${slot.id} references missing owner layout ${slot.ownerLayoutId}`, + ); + } + + return layoutRouteKeys.get(layout.treePath) ?? treePathToRouteLiteral(layout.treePath); +} + function treePathToRouteLiteral(treePath: string): string { if (treePath === "/") return "/"; diff --git a/tests/typegen.test.ts b/tests/typegen.test.ts index 0f1153b5c..e22a77e37 100644 --- a/tests/typegen.test.ts +++ b/tests/typegen.test.ts @@ -101,6 +101,21 @@ describe("generateRouteTypes", () => { }); }); + it("maps slots to the owning layout when the slot directory has no layout", async () => { + await withTempProject(async (root) => { + await writeProjectFile(root, "app/layout.tsx", EMPTY_LAYOUT); + await writeProjectFile(root, "app/dashboard/page.tsx", EMPTY_PAGE); + await writeProjectFile(root, "app/dashboard/@analytics/default.tsx", EMPTY_PAGE); + + const outputPath = await generateRouteTypes({ root }); + const generated = await readFile(outputPath, "utf-8"); + + expect(generated).toContain('type LayoutRoute = "/";'); + expect(generated).toContain('"/": "analytics";'); + expect(generated).not.toContain('"/dashboard": "analytics";'); + }); + }); + it("updates generated route helper types when App Router files are added in dev", async () => { await withTempProject(async (root) => { await writeProjectFile(root, "app/layout.tsx", EMPTY_LAYOUT); From e323f6d27065b42ba579cecf8266e5b04a15d787 Mon Sep 17 00:00:00 2001 From: JamesbbBriz Date: Mon, 11 May 2026 10:09:31 +0800 Subject: [PATCH 09/18] Preserve slot-local layout type keys --- .../vinext/src/routing/app-route-graph.ts | 27 +++++++++++++++++++ packages/vinext/src/typegen.ts | 6 +---- tests/app-route-graph.test.ts | 20 ++++++++++++++ tests/typegen.test.ts | 16 +++++++++++ 4 files changed, 64 insertions(+), 5 deletions(-) diff --git a/packages/vinext/src/routing/app-route-graph.ts b/packages/vinext/src/routing/app-route-graph.ts index aea1a033e..0a5e57a28 100644 --- a/packages/vinext/src/routing/app-route-graph.ts +++ b/packages/vinext/src/routing/app-route-graph.ts @@ -460,6 +460,27 @@ function createStaticSegmentGraph(routes: readonly AppRouteGraphRoute[]): Static for (const slot of route.parallelSlots) { const ownerLayoutId = findSlotOwnerLayoutId(route, slot); const defaultId = slot.defaultPath ? createAppRouteGraphDefaultId(slot.id) : null; + if (slot.layoutPath) { + const slotLayoutTreePath = createSlotLayoutTreePath(slot); + const slotLayoutId = createAppRouteGraphLayoutId(slotLayoutTreePath); + const existingLayout = layouts.get(slotLayoutId); + if (existingLayout) { + assertRouteManifestRootBoundary( + "layout", + route, + slotLayoutId, + existingLayout.rootBoundaryId, + ); + } + const slotLayoutRouteParts = convertTreePathToRouteParts(slotLayoutTreePath); + layouts.set(slotLayoutId, { + id: slotLayoutId, + treePath: slotLayoutTreePath, + patternParts: slotLayoutRouteParts.urlSegments, + paramNames: slotLayoutRouteParts.params, + rootBoundaryId: route.ids.rootBoundary, + }); + } slots.set(slot.id, { id: slot.id, key: slot.key, @@ -515,6 +536,12 @@ function findSlotOwnerLayoutId( return route.ids.layouts[slot.layoutIndex] ?? null; } +function createSlotLayoutTreePath(slot: AppRouteGraphParallelSlot): string { + const slotSegment = `@${slot.name}`; + if (slot.ownerTreePath === "/") return `/${slotSegment}`; + return `${slot.ownerTreePath}/${slotSegment}`; +} + function createRouteManifestSlotBinding( route: AppRouteGraphRoute, slot: AppRouteGraphParallelSlot, diff --git a/packages/vinext/src/typegen.ts b/packages/vinext/src/typegen.ts index 2da37f571..f00148129 100644 --- a/packages/vinext/src/typegen.ts +++ b/packages/vinext/src/typegen.ts @@ -263,7 +263,7 @@ function treePathToScopedLayoutRouteLiteral(treePath: string): string { const segments = treePath .split("/") .filter(Boolean) - .filter((segment) => !isSlotSegment(segment) && segment !== ".") + .filter((segment) => segment !== ".") .map((segment) => decodeRouteSegment(segment)); return segments.length === 0 ? "/" : `/${segments.join("/")}`; } @@ -274,10 +274,6 @@ function isInvisibleSegment(segment: string): boolean { ); } -function isSlotSegment(segment: string): boolean { - return segment.startsWith("@"); -} - function addRoute( routes: string[], params: Map, diff --git a/tests/app-route-graph.test.ts b/tests/app-route-graph.test.ts index cb7ad3e6c..cdef0774c 100644 --- a/tests/app-route-graph.test.ts +++ b/tests/app-route-graph.test.ts @@ -314,6 +314,26 @@ describe("App Router route graph builder", () => { }); }); + it("materializes slot-local layouts in the static segment graph", async () => { + await withTempApp(async (appDir) => { + await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT); + await writeAppFile(appDir, "page.tsx", EMPTY_PAGE); + await writeAppFile(appDir, "@modal/layout.tsx", EMPTY_LAYOUT); + await writeAppFile(appDir, "@modal/page.tsx", EMPTY_PAGE); + + const graph = await buildAppRouteGraph(appDir, createValidFileMatcher()); + const { segmentGraph } = graph.routeManifest; + + expect(segmentGraph.layouts.get("layout:/@modal")).toEqual({ + id: "layout:/@modal", + treePath: "/@modal", + patternParts: [], + paramNames: [], + rootBoundaryId: "root-boundary:/", + }); + }); + }); + it("exposes a minimal RouteManifest read model keyed by semantic ids", async () => { await withTempApp(async (appDir) => { await createSemanticIdsFixture(appDir); diff --git a/tests/typegen.test.ts b/tests/typegen.test.ts index e22a77e37..9b35f6d45 100644 --- a/tests/typegen.test.ts +++ b/tests/typegen.test.ts @@ -116,6 +116,22 @@ describe("generateRouteTypes", () => { }); }); + it("keeps slot-local layouts separate from their owning layout", async () => { + await withTempProject(async (root) => { + await writeProjectFile(root, "app/layout.tsx", EMPTY_LAYOUT); + await writeProjectFile(root, "app/page.tsx", EMPTY_PAGE); + await writeProjectFile(root, "app/@modal/layout.tsx", EMPTY_LAYOUT); + await writeProjectFile(root, "app/@modal/page.tsx", EMPTY_PAGE); + + const outputPath = await generateRouteTypes({ root }); + const generated = await readFile(outputPath, "utf-8"); + + expect(generated).toContain('type LayoutRoute = "/" | "/@modal";'); + expect(generated).toContain('"/": "modal";'); + expect(generated).toContain('"/@modal": never;'); + }); + }); + it("updates generated route helper types when App Router files are added in dev", async () => { await withTempProject(async (root) => { await writeProjectFile(root, "app/layout.tsx", EMPTY_LAYOUT); From 5a8b06cb511b928449351077c91e8aac85e9f75b Mon Sep 17 00:00:00 2001 From: JamesbbBriz Date: Mon, 11 May 2026 11:22:11 +0800 Subject: [PATCH 10/18] Generate next-env.d.ts during typegen --- packages/vinext/src/typegen.ts | 18 ++++++++++++++++++ tests/typegen.test.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/packages/vinext/src/typegen.ts b/packages/vinext/src/typegen.ts index f00148129..4a8951584 100644 --- a/packages/vinext/src/typegen.ts +++ b/packages/vinext/src/typegen.ts @@ -12,6 +12,14 @@ type GenerateRouteTypesOptions = { type ParamShape = Map; +const NEXT_ENV_FILE_CONTENT = `/// +/// +import "./.next/types/routes.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. +`; + export async function generateRouteTypes(options: GenerateRouteTypesOptions): Promise { const root = path.resolve(options.root); const appDir = options.appDir ? path.resolve(options.appDir) : await findAppDir(root); @@ -23,9 +31,19 @@ export async function generateRouteTypes(options: GenerateRouteTypesOptions): Pr await fs.mkdir(path.dirname(outPath), { recursive: true }); await fs.writeFile(outPath, content, "utf-8"); + await ensureNextEnvFile(root); return outPath; } +async function ensureNextEnvFile(root: string): Promise { + const envPath = path.join(root, "next-env.d.ts"); + try { + await fs.access(envPath); + } catch { + await fs.writeFile(envPath, NEXT_ENV_FILE_CONTENT, "utf-8"); + } +} + type RouteTypeModel = { pageRoutes: string[]; layoutRoutes: string[]; diff --git a/tests/typegen.test.ts b/tests/typegen.test.ts index 9b35f6d45..1a48d97c1 100644 --- a/tests/typegen.test.ts +++ b/tests/typegen.test.ts @@ -132,6 +132,34 @@ describe("generateRouteTypes", () => { }); }); + it("writes a Next-compatible next-env.d.ts stub when one is missing", async () => { + await withTempProject(async (root) => { + await writeProjectFile(root, "app/layout.tsx", EMPTY_LAYOUT); + await writeProjectFile(root, "app/page.tsx", EMPTY_PAGE); + + await generateRouteTypes({ root }); + const generated = await readFile(path.join(root, "next-env.d.ts"), "utf-8"); + + expect(generated).toContain('/// '); + expect(generated).toContain('/// '); + expect(generated).toContain('import "./.next/types/routes.d.ts";'); + }); + }); + + it("preserves an existing next-env.d.ts", async () => { + await withTempProject(async (root) => { + await writeProjectFile(root, "app/layout.tsx", EMPTY_LAYOUT); + await writeProjectFile(root, "app/page.tsx", EMPTY_PAGE); + const customContent = '/// \n'; + await writeProjectFile(root, "next-env.d.ts", customContent); + + await generateRouteTypes({ root }); + const preserved = await readFile(path.join(root, "next-env.d.ts"), "utf-8"); + + expect(preserved).toBe(customContent); + }); + }); + it("updates generated route helper types when App Router files are added in dev", async () => { await withTempProject(async (root) => { await writeProjectFile(root, "app/layout.tsx", EMPTY_LAYOUT); From eb1acff5d4d61991f92acf48857fa236d256e947 Mon Sep 17 00:00:00 2001 From: JamesbbBriz Date: Wed, 13 May 2026 00:59:42 +0800 Subject: [PATCH 11/18] Address review feedback for route typegen --- packages/vinext/src/index.ts | 2 ++ .../vinext/src/routing/app-route-graph.ts | 7 +++++- packages/vinext/src/typegen.ts | 24 ++++++++++++------- tests/typegen.test.ts | 4 ++++ 4 files changed, 28 insertions(+), 9 deletions(-) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index c2880f08e..da2601e1f 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -2216,6 +2216,8 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { appRouteTypeGeneration = drainAppRouteTypeGeneration().finally(() => { appRouteTypeGeneration = null; + // A watcher event may have arrived after the drain loop's final + // check but before this finally runs; restart the loop if so. if (appRouteTypeGenerationPending) regenerateAppRouteTypes(); }); } diff --git a/packages/vinext/src/routing/app-route-graph.ts b/packages/vinext/src/routing/app-route-graph.ts index 0a5e57a28..e42bc6bf4 100644 --- a/packages/vinext/src/routing/app-route-graph.ts +++ b/packages/vinext/src/routing/app-route-graph.ts @@ -461,6 +461,11 @@ function createStaticSegmentGraph(routes: readonly AppRouteGraphRoute[]): Static const ownerLayoutId = findSlotOwnerLayoutId(route, slot); const defaultId = slot.defaultPath ? createAppRouteGraphDefaultId(slot.id) : null; if (slot.layoutPath) { + // Materialize the slot-local layout as its own entry so consumers + // (e.g. typegen) can distinguish it from the owning layout. Note + // that this layout may have zero entries in `slots`: the slot + // itself is registered below against `ownerLayoutId`, which points + // to the ancestor layout that owns the slot prop. const slotLayoutTreePath = createSlotLayoutTreePath(slot); const slotLayoutId = createAppRouteGraphLayoutId(slotLayoutTreePath); const existingLayout = layouts.get(slotLayoutId); @@ -1927,7 +1932,7 @@ function collectInterceptingPages( * Used by computeInterceptTarget, convertSegmentsToRouteParts, and * hasRemainingVisibleSegments — keep this the single source of truth. */ -function isInvisibleSegment(segment: string): boolean { +export function isInvisibleSegment(segment: string): boolean { if (segment === ".") return true; if (segment.startsWith("(") && segment.endsWith(")")) return true; if (segment.startsWith("@")) return true; diff --git a/packages/vinext/src/typegen.ts b/packages/vinext/src/typegen.ts index 4a8951584..c84ce0e73 100644 --- a/packages/vinext/src/typegen.ts +++ b/packages/vinext/src/typegen.ts @@ -1,5 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { isInvisibleSegment } from "./routing/app-route-graph.js"; import { appRouteGraph } from "./routing/app-router.js"; import { patternToNextFormat } from "./routing/route-validation.js"; import { decodeRouteSegment } from "./routing/utils.js"; @@ -70,11 +71,15 @@ async function collectRouteTypeModel( const model = emptyRouteTypeModel(); const segmentGraph = graph.routeManifest.segmentGraph; const layoutRouteKeys = createLayoutRouteKeyMap(segmentGraph.layouts.values()); + const pageRouteSet = new Set(); + const layoutRouteSet = new Set(); + const routeHandlerRouteSet = new Set(); for (const route of segmentGraph.pages.values()) { const routeEntry = segmentGraph.routes.get(route.routeId); addRoute( model.pageRoutes, + pageRouteSet, model.params, patternToNextFormat(route.pattern), paramsForPatternParts(routeEntry?.patternParts ?? []), @@ -85,6 +90,7 @@ async function collectRouteTypeModel( const routeEntry = segmentGraph.routes.get(route.routeId); addRoute( model.routeHandlerRoutes, + routeHandlerRouteSet, model.params, patternToNextFormat(route.pattern), paramsForPatternParts(routeEntry?.patternParts ?? []), @@ -93,7 +99,13 @@ async function collectRouteTypeModel( for (const layout of segmentGraph.layouts.values()) { const route = layoutRouteKeys.get(layout.treePath) ?? treePathToRouteLiteral(layout.treePath); - addRoute(model.layoutRoutes, model.params, route, paramsForPatternParts(layout.patternParts)); + addRoute( + model.layoutRoutes, + layoutRouteSet, + model.params, + route, + paramsForPatternParts(layout.patternParts), + ); } for (const slot of segmentGraph.slots.values()) { @@ -286,19 +298,15 @@ function treePathToScopedLayoutRouteLiteral(treePath: string): string { return segments.length === 0 ? "/" : `/${segments.join("/")}`; } -function isInvisibleSegment(segment: string): boolean { - return ( - segment === "." || (segment.startsWith("(") && segment.endsWith(")")) || segment.startsWith("@") - ); -} - function addRoute( routes: string[], + seen: Set, params: Map, route: string, paramShape: ParamShape, ): void { - if (!routes.includes(route)) { + if (!seen.has(route)) { + seen.add(route); routes.push(route); routes.sort(compareStrings); } diff --git a/tests/typegen.test.ts b/tests/typegen.test.ts index 1a48d97c1..917c37d4c 100644 --- a/tests/typegen.test.ts +++ b/tests/typegen.test.ts @@ -167,6 +167,10 @@ describe("generateRouteTypes", () => { let server: ViteDevServer | null = null; try { + // `appDir` in the vinext plugin options names the project root, not + // the App Router directory; the plugin auto-detects `app/` (or + // `src/app/`) under it. Pass the project root explicitly here so + // the dev server uses the same root path for both Vite and vinext. server = await createServer({ root, logLevel: "silent", From 09ccb8ef68f2d40a6b78d75994aa6139e6358bb9 Mon Sep 17 00:00:00 2001 From: JamesbbBriz Date: Wed, 13 May 2026 04:52:58 +0800 Subject: [PATCH 12/18] Defer route sort and tighten typegen helpers --- packages/vinext/src/typegen.ts | 38 ++++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/packages/vinext/src/typegen.ts b/packages/vinext/src/typegen.ts index c84ce0e73..29517dc62 100644 --- a/packages/vinext/src/typegen.ts +++ b/packages/vinext/src/typegen.ts @@ -39,9 +39,9 @@ export async function generateRouteTypes(options: GenerateRouteTypesOptions): Pr async function ensureNextEnvFile(root: string): Promise { const envPath = path.join(root, "next-env.d.ts"); try { - await fs.access(envPath); - } catch { - await fs.writeFile(envPath, NEXT_ENV_FILE_CONTENT, "utf-8"); + await fs.writeFile(envPath, NEXT_ENV_FILE_CONTENT, { encoding: "utf-8", flag: "wx" }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; } } @@ -108,18 +108,32 @@ async function collectRouteTypeModel( ); } + const layoutSlotSets = new Map>(); for (const slot of segmentGraph.slots.values()) { const layoutRoute = layoutRouteKeyForSlot(slot, segmentGraph.layouts, layoutRouteKeys); if (!layoutRoute) continue; - const slots = model.layoutSlots.get(layoutRoute) ?? []; - if (!slots.includes(slot.name)) { - slots.push(slot.name); - slots.sort(compareStrings); + let slotNames = layoutSlotSets.get(layoutRoute); + if (!slotNames) { + slotNames = new Set(); + layoutSlotSets.set(layoutRoute, slotNames); + model.layoutSlots.set(layoutRoute, []); + } + if (!slotNames.has(slot.name)) { + slotNames.add(slot.name); + model.layoutSlots.get(layoutRoute)?.push(slot.name); } - model.layoutSlots.set(layoutRoute, slots); } + // Sort all collected route lists once after collection. addRoute() and the + // slot loop above intentionally skip per-insertion sorts to keep collection + // O(n) — the rendered output relies on stable sorted order, so the single + // pass here is enough. + model.pageRoutes.sort(compareStrings); + model.layoutRoutes.sort(compareStrings); + model.routeHandlerRoutes.sort(compareStrings); + for (const slotNames of model.layoutSlots.values()) slotNames.sort(compareStrings); + return model; } @@ -276,6 +290,7 @@ function layoutRouteKeyForSlot( return layoutRouteKeys.get(layout.treePath) ?? treePathToRouteLiteral(layout.treePath); } +/** Convert a layout tree path to its URL route literal, stripping invisible segments. */ function treePathToRouteLiteral(treePath: string): string { if (treePath === "/") return "/"; @@ -287,6 +302,12 @@ function treePathToRouteLiteral(treePath: string): string { return segments.length === 0 ? "/" : `/${segments.join("/")}`; } +/** + * Convert a layout tree path to a scoped route literal that preserves + * route-group and `@slot` segments. Used only as a fallback key when multiple + * layouts collapse to the same URL route literal, so consumers can keep their + * slot/params typings distinct. + */ function treePathToScopedLayoutRouteLiteral(treePath: string): string { if (treePath === "/") return "/"; @@ -308,7 +329,6 @@ function addRoute( if (!seen.has(route)) { seen.add(route); routes.push(route); - routes.sort(compareStrings); } params.set(route, paramShape); } From 3b78188999b7df0e47c79699f9264db297fc5157 Mon Sep 17 00:00:00 2001 From: JamesbbBriz Date: Wed, 13 May 2026 17:30:21 +0800 Subject: [PATCH 13/18] Stabilize Windows create-next-app smoke check --- .github/workflows/ci.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 92158f0cc..72920e05c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -165,11 +165,12 @@ jobs: working-directory: ${{ runner.temp }}/cna-test shell: bash run: | - vp exec vite dev --port 3099 & + vp exec vite dev --host 127.0.0.1 --port 3099 & SERVER_PID=$! for i in $(seq 1 30); do - STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3099/ || true) + STATUS=$(curl --max-time 2 -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3099/ || true) + echo "HTTP status from dev server: ${STATUS:-curl failed} (attempt $i)" if [ "$STATUS" = "200" ]; then echo "Server responded with HTTP 200 (attempt $i)" kill "$SERVER_PID" 2>/dev/null || true From 331ad5df6fa5c69bbbe949fee25cbabcb2766c9e Mon Sep 17 00:00:00 2001 From: JamesbbBriz Date: Wed, 13 May 2026 18:42:33 +0800 Subject: [PATCH 14/18] Address route typegen review fixes --- packages/vinext/src/typegen.ts | 14 +++++++++++++- tests/typegen.test.ts | 3 ++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/vinext/src/typegen.ts b/packages/vinext/src/typegen.ts index 29517dc62..49259d3fe 100644 --- a/packages/vinext/src/typegen.ts +++ b/packages/vinext/src/typegen.ts @@ -15,7 +15,7 @@ type ParamShape = Map; const NEXT_ENV_FILE_CONTENT = `/// /// -import "./.next/types/routes.d.ts"; +/// // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. @@ -330,9 +330,21 @@ function addRoute( seen.add(route); routes.push(route); } + const existingParamShape = params.get(route); + if (existingParamShape && !paramShapesEqual(existingParamShape, paramShape)) { + throw new Error(`[vinext] Conflicting route param shapes generated for ${route}`); + } params.set(route, paramShape); } +function paramShapesEqual(left: ParamShape, right: ParamShape): boolean { + if (left.size !== right.size) return false; + for (const [name, kind] of left) { + if (right.get(name) !== kind) return false; + } + return true; +} + function uniqueSorted(values: readonly string[]): string[] { return Array.from(new Set(values)).sort(compareStrings); } diff --git a/tests/typegen.test.ts b/tests/typegen.test.ts index 917c37d4c..1bc5725df 100644 --- a/tests/typegen.test.ts +++ b/tests/typegen.test.ts @@ -142,7 +142,8 @@ describe("generateRouteTypes", () => { expect(generated).toContain('/// '); expect(generated).toContain('/// '); - expect(generated).toContain('import "./.next/types/routes.d.ts";'); + expect(generated).toContain('/// '); + expect(generated).not.toContain('import "./.next/types/routes.d.ts";'); }); }); From 30e81da836a28e474398524675660d5b8327df88 Mon Sep 17 00:00:00 2001 From: JamesbbBriz Date: Thu, 14 May 2026 01:16:02 +0800 Subject: [PATCH 15/18] Match Next route type import --- packages/vinext/src/typegen.ts | 2 +- tests/typegen.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/vinext/src/typegen.ts b/packages/vinext/src/typegen.ts index 49259d3fe..4b32d8ee4 100644 --- a/packages/vinext/src/typegen.ts +++ b/packages/vinext/src/typegen.ts @@ -15,7 +15,7 @@ type ParamShape = Map; const NEXT_ENV_FILE_CONTENT = `/// /// -/// +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/tests/typegen.test.ts b/tests/typegen.test.ts index 1bc5725df..6659d6918 100644 --- a/tests/typegen.test.ts +++ b/tests/typegen.test.ts @@ -142,8 +142,8 @@ describe("generateRouteTypes", () => { expect(generated).toContain('/// '); expect(generated).toContain('/// '); - expect(generated).toContain('/// '); - expect(generated).not.toContain('import "./.next/types/routes.d.ts";'); + expect(generated).toContain('import "./.next/types/routes.d.ts";'); + expect(generated).not.toContain('/// '); }); }); From 74f16ccd8686c4f9e56ad05bcaa2bfd9b997323b Mon Sep 17 00:00:00 2001 From: JamesbbBriz Date: Thu, 14 May 2026 01:23:03 +0800 Subject: [PATCH 16/18] Stabilize Windows create-next-app smoke --- .github/workflows/ci.yml | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72920e05c..abf5567ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -167,20 +167,39 @@ jobs: run: | vp exec vite dev --host 127.0.0.1 --port 3099 & SERVER_PID=$! + trap 'kill "$SERVER_PID" 2>/dev/null || true' EXIT - for i in $(seq 1 30); do - STATUS=$(curl --max-time 2 -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3099/ || true) - echo "HTTP status from dev server: ${STATUS:-curl failed} (attempt $i)" + for i in $(seq 1 45); do + STATUS=$(node -e ' + const http = require("node:http"); + let done = false; + function finish(status) { + if (done) return; + done = true; + console.log(status); + process.exit(status === 200 ? 0 : 1); + } + const req = http.get("http://127.0.0.1:3099/", (res) => { + res.resume(); + finish(res.statusCode); + }); + req.setTimeout(10000, () => { + req.destroy(); + finish("timeout"); + }); + req.on("error", () => { + finish("000"); + }); + ' || true) + echo "HTTP status from dev server: ${STATUS:-request failed} (attempt $i)" if [ "$STATUS" = "200" ]; then echo "Server responded with HTTP 200 (attempt $i)" - kill "$SERVER_PID" 2>/dev/null || true exit 0 fi sleep 1 done - echo "Server did not respond with HTTP 200 within 30 seconds" - kill "$SERVER_PID" 2>/dev/null || true + echo "Server did not respond with HTTP 200 before the retry limit" exit 1 e2e: From 8edcb5066b90010b3e4cf3dc55e158337a4ec54e Mon Sep 17 00:00:00 2001 From: JamesbbBriz Date: Fri, 15 May 2026 14:52:34 +0800 Subject: [PATCH 17/18] Skip redundant route param shape writes --- packages/vinext/src/typegen.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/vinext/src/typegen.ts b/packages/vinext/src/typegen.ts index 4b32d8ee4..3126ec477 100644 --- a/packages/vinext/src/typegen.ts +++ b/packages/vinext/src/typegen.ts @@ -331,8 +331,11 @@ function addRoute( routes.push(route); } const existingParamShape = params.get(route); - if (existingParamShape && !paramShapesEqual(existingParamShape, paramShape)) { - throw new Error(`[vinext] Conflicting route param shapes generated for ${route}`); + if (existingParamShape) { + if (!paramShapesEqual(existingParamShape, paramShape)) { + throw new Error(`[vinext] Conflicting route param shapes generated for ${route}`); + } + return; } params.set(route, paramShape); } From 60117bb5cc47c646d1be1095edeb370b73777d94 Mon Sep 17 00:00:00 2001 From: JamesbbBriz Date: Wed, 20 May 2026 12:28:33 +0800 Subject: [PATCH 18/18] Defer dev route type generation --- packages/vinext/src/index.ts | 6 +++++- tests/typegen.test.ts | 33 ++++++++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index da2601e1f..496a90f7f 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -946,7 +946,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { instrumentationPath = findInstrumentationFile(root, fileMatcher); instrumentationClientPath = findInstrumentationClientFile(root, fileMatcher); middlewarePath = findMiddlewareFile(root, fileMatcher); - await writeRouteTypes(); + if (env?.command === "build") { + await writeRouteTypes(); + } // Merge env from next.config.js with NEXT_PUBLIC_* env vars const defines = getNextPublicEnvDefines(); @@ -2222,6 +2224,8 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { }); } + regenerateAppRouteTypes(); + // Node throws on unhandled 'error' events on sockets. When a browser // drops the connection mid-response (common in dev: HMR triggers a // reload while an RSC stream is still flushing), the next res.write diff --git a/tests/typegen.test.ts b/tests/typegen.test.ts index 6659d6918..01f6d822c 100644 --- a/tests/typegen.test.ts +++ b/tests/typegen.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vite-plus/test"; -import { createServer, type ViteDevServer } from "vite-plus"; +import { createLogger, createServer, type ViteDevServer } from "vite-plus"; import os from "node:os"; import path from "node:path"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; @@ -210,4 +210,35 @@ describe("generateRouteTypes", () => { } }); }); + + it("does not block dev server startup when initial route type generation fails", async () => { + await withTempProject(async (root) => { + await writeProjectFile(root, "app/layout.tsx", EMPTY_LAYOUT); + await writeProjectFile(root, "app/page.tsx", EMPTY_PAGE); + await writeProjectFile(root, ".next", "not a directory\n"); + const warnings: string[] = []; + const logger = createLogger("silent"); + logger.warn = (message) => { + warnings.push(message); + }; + + let server: ViteDevServer | null = null; + try { + server = await createServer({ + root, + customLogger: logger, + plugins: [vinext({ appDir: root })], + }); + + expect(server).toBeTruthy(); + await eventually(async () => { + expect( + warnings.some((warning) => warning.includes("Failed to regenerate route types")), + ).toBe(true); + }); + } finally { + await server?.close(); + } + }); + }); });