-
Notifications
You must be signed in to change notification settings - Fork 30.5k
feat: generate types for next/root-params during build and dev #90150
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: canary
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| import fs from 'fs' | ||
| import path from 'path' | ||
|
|
||
| export type RootParamKind = 'dynamic' | 'catchall' | 'optional-catchall' | ||
|
|
||
| /** | ||
| * Generates TypeScript type definitions for the next/root-params virtual module. | ||
| * Creates typed getter functions for each root param found in the app's root layouts. | ||
| */ | ||
| export function generateRootParamsTypes( | ||
| rootParams: Map<string, RootParamKind> | ||
| ): string { | ||
| const entries = Array.from(rootParams.entries()).sort(([a], [b]) => | ||
| a.localeCompare(b) | ||
| ) | ||
|
|
||
| const functions = entries.map(([name, kind]) => { | ||
| const returnType = | ||
| kind === 'dynamic' | ||
| ? 'Promise<string>' | ||
| : kind === 'catchall' | ||
| ? 'Promise<string[]>' | ||
| : 'Promise<string[] | undefined>' | ||
| return ` export function ${name}(): ${returnType}` | ||
| }) | ||
|
|
||
| return `// Type definitions for Next.js root params | ||
|
|
||
| declare module 'next/root-params' { | ||
| ${functions.join('\n')} | ||
| } | ||
| ` | ||
| } | ||
|
|
||
| /** | ||
| * Writes root params type definitions to a file if rootParams exist. | ||
| * This is used by both the CLI (next type-gen) and dev server to generate | ||
| * root-params.d.ts in the types directory. | ||
| */ | ||
| export function writeRootParamsTypes( | ||
| rootParams: Map<string, RootParamKind> | undefined, | ||
| filePath: string | ||
| ) { | ||
| if (!rootParams || rootParams.size === 0) { | ||
| return | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| const dirname = path.dirname(filePath) | ||
|
|
||
| if (!fs.existsSync(dirname)) { | ||
| fs.mkdirSync(dirname, { recursive: true }) | ||
| } | ||
|
|
||
| const content = generateRootParamsTypes(rootParams) | ||
| fs.writeFileSync(filePath, content) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,6 +13,11 @@ import { | |
| generateValidatorFileStrict, | ||
| generateRouteTypesFileStrict, | ||
| } from './typegen' | ||
| import { | ||
| writeRootParamsTypes, | ||
| type RootParamKind, | ||
| } from './root-params-type-utils' | ||
| import { getSegmentParam } from '../../../shared/lib/router/utils/get-segment-param' | ||
| import { tryToParsePath } from '../../../lib/try-to-parse-path' | ||
| import { | ||
| extractInterceptionRouteInformation, | ||
|
|
@@ -48,6 +53,8 @@ export interface RouteTypesManifest { | |
| pageApiRoutes: Set<string> | ||
| /** Direct mapping from file paths to routes for validation */ | ||
| filePathToRoute: Map<string, string> | ||
| /** Root params collected from root layouts for generating root-params.d.ts */ | ||
| rootParams?: Map<string, RootParamKind> | ||
| } | ||
|
|
||
| // Convert a custom-route source string (`/blog/:slug`, `/docs/:path*`, ...) | ||
|
|
@@ -144,6 +151,68 @@ function resolveInterceptingRoute(route: string): string { | |
| } | ||
| } | ||
|
|
||
| /** | ||
| * Identifies root layouts and collects their dynamic segments as root params. | ||
| * A root layout is the shallowest layout in each branch of the app directory. | ||
| */ | ||
| function collectRootParamsFromLayouts( | ||
| layoutRoutes: RouteInfo[] | ||
| ): Map<string, RootParamKind> { | ||
| const rootParams = new Map<string, RootParamKind>() | ||
|
|
||
| // Sort by depth (fewer path segments = shallower = more likely to be root) | ||
| const sorted = [...layoutRoutes].sort( | ||
| (a, b) => a.route.split('/').length - b.route.split('/').length | ||
| ) | ||
|
|
||
| const rootLayoutRoutes: string[] = [] | ||
|
|
||
| for (const { route } of sorted) { | ||
| // Skip internal routes | ||
| if ( | ||
| route === UNDERSCORE_GLOBAL_ERROR_ROUTE || | ||
| route === UNDERSCORE_NOT_FOUND_ROUTE | ||
| ) { | ||
| continue | ||
| } | ||
|
|
||
| // A layout is a root layout if no already-found root layout is an ancestor of it | ||
| const hasAncestorRootLayout = rootLayoutRoutes.some((rootRoute) => | ||
| rootRoute === '/' ? route !== '/' : route.startsWith(rootRoute + '/') | ||
| ) | ||
|
|
||
| if (hasAncestorRootLayout) continue | ||
|
|
||
| rootLayoutRoutes.push(route) | ||
|
|
||
| // Extract dynamic segments from this root layout's route | ||
| for (const segment of route.split('/')) { | ||
| const param = getSegmentParam(segment) | ||
| if (param === null) continue | ||
|
|
||
| const kind: RootParamKind = | ||
| param.paramType === 'optional-catchall' | ||
| ? 'optional-catchall' | ||
| : param.paramType === 'catchall' | ||
| ? 'catchall' | ||
| : 'dynamic' | ||
|
Comment on lines
+193
to
+198
|
||
|
|
||
| // If the same param name appears in multiple root layouts with different | ||
| // kinds, keep the most permissive type. | ||
| const existing = rootParams.get(param.paramName) | ||
| if ( | ||
| !existing || | ||
| kind === 'optional-catchall' || | ||
| (kind === 'catchall' && existing === 'dynamic') | ||
| ) { | ||
| rootParams.set(param.paramName, kind) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return rootParams | ||
| } | ||
|
|
||
| /** | ||
| * Creates a route types manifest from processed route data | ||
| * (used for both build and dev) | ||
|
|
@@ -350,6 +419,8 @@ export async function createRouteTypesManifest({ | |
| } | ||
| } | ||
|
|
||
| manifest.rootParams = collectRootParamsFromLayouts(layoutRoutes) | ||
|
|
||
| return manifest | ||
| } | ||
|
|
||
|
|
@@ -413,6 +484,7 @@ export async function writeRouteTypesEntryFile( | |
| options: { | ||
| strictRouteTypes: boolean | ||
| typedRoutes: boolean | ||
| rootParams?: boolean | ||
| } | ||
| ) { | ||
| const entryDir = path.dirname(entryFilePath) | ||
|
|
@@ -432,6 +504,10 @@ export async function writeRouteTypesEntryFile( | |
| `export type * from "${prefix}route-types.d.ts";`, | ||
| ] | ||
|
|
||
| if (options.rootParams) { | ||
| lines.push(`import "${prefix}root-params.d.ts";`) | ||
| } | ||
|
|
||
| if (options.strictRouteTypes) { | ||
| lines.push(`import "${prefix}cache-life.d.ts";`) | ||
| lines.push(`import "${prefix}validator.ts";`) | ||
|
|
@@ -445,3 +521,10 @@ export async function writeRouteTypesEntryFile( | |
|
|
||
| await fs.promises.writeFile(entryFilePath, lines.join('\n')) | ||
| } | ||
|
|
||
| export function writeRootParamsTypesFile( | ||
| manifest: RouteTypesManifest, | ||
| filePath: string | ||
| ) { | ||
| writeRootParamsTypes(manifest.rootParams, filePath) | ||
| } | ||
|
Comment on lines
+525
to
+530
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| // This file is type-checked by typecheck.test.ts after building the fixture. | ||
| // It verifies that the generated next/root-params types are correct. | ||
| import { lang, locale, path } from 'next/root-params' | ||
|
|
||
| // lang and locale are simple dynamic segments → Promise<string> | ||
| const langResult: Promise<string> = lang() | ||
| const localeResult: Promise<string> = locale() | ||
|
|
||
| // path appears in both catch-all and optional-catch-all layouts → | ||
| // most permissive type wins: Promise<string[] | undefined> | ||
| const pathResult: Promise<string[] | undefined> = path() | ||
|
|
||
| export { langResult, localeResult, pathResult } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| /* eslint-env jest */ | ||
| import path from 'path' | ||
| import fs from 'fs-extra' | ||
| import { nextBuild } from 'next-test-utils' | ||
| import execa from 'execa' | ||
|
|
||
| const simpleFixtureDir = path.join(__dirname, 'fixtures', 'simple') | ||
| const multipleRootsFixtureDir = path.join( | ||
| __dirname, | ||
| 'fixtures', | ||
| 'multiple-roots' | ||
| ) | ||
|
|
||
| // Turbopack doesn't use this type generation path (handled in Rust) | ||
| describe('root params type generation', () => { | ||
| describe('simple fixture', () => { | ||
| beforeAll(async () => { | ||
| await nextBuild(simpleFixtureDir, [], { stderr: true }) | ||
| }) | ||
|
|
||
| it('should generate root-params.d.ts with correct types', async () => { | ||
| const dts = ( | ||
| await fs.readFile( | ||
| path.join(simpleFixtureDir, '.next', 'types', 'root-params.d.ts') | ||
| ) | ||
| ).toString() | ||
|
|
||
| // lang and locale are simple dynamic segments → Promise<string> | ||
| expect(dts).toContain(`export function lang(): Promise<string>`) | ||
| expect(dts).toContain(`export function locale(): Promise<string>`) | ||
|
|
||
| // path appears in catch-all and optional-catch-all → most permissive wins | ||
| expect(dts).toContain( | ||
| `export function path(): Promise<string[] | undefined>` | ||
| ) | ||
| }) | ||
|
|
||
| it('should include root-params.d.ts import in entry file', async () => { | ||
| const entryFile = ( | ||
| await fs.readFile( | ||
| path.join(simpleFixtureDir, '.next', 'types', 'routes.d.ts') | ||
| ) | ||
| ).toString() | ||
|
|
||
| expect(entryFile).toContain(`import "./root-params.d.ts"`) | ||
| }) | ||
|
|
||
| it('should type-check correctly', async () => { | ||
| const result = await execa('tsc', ['--noEmit'], { | ||
| cwd: simpleFixtureDir, | ||
| reject: false, | ||
| }) | ||
| expect(result.stderr).not.toContain('error TS') | ||
| expect(result.stdout).not.toContain('error TS') | ||
| }) | ||
| }) | ||
|
|
||
| describe('multiple-roots fixture', () => { | ||
| beforeAll(async () => { | ||
| await nextBuild(multipleRootsFixtureDir, [], { stderr: true }) | ||
| }) | ||
|
|
||
| it('should generate root-params.d.ts with correct types', async () => { | ||
| const dts = ( | ||
| await fs.readFile( | ||
| path.join( | ||
| multipleRootsFixtureDir, | ||
| '.next', | ||
| 'types', | ||
| 'root-params.d.ts' | ||
| ) | ||
| ) | ||
| ).toString() | ||
|
|
||
| // Only the dashboard subtree has a dynamic segment | ||
| expect(dts).toContain(`export function id(): Promise<string>`) | ||
| // landing layout has no params | ||
| expect(dts).not.toContain('lang') | ||
| }) | ||
| }) | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing
awaitbeforewriteRootParamsTypesFile. Even though the function is currently synchronous, it should be called withawaitfor consistency with line 1425 wherewriteCacheLifeTypesFileis awaited, and to prevent issues if the function becomes async in the future.