Skip to content

Commit 7533724

Browse files
committed
feat: generate types for next/root-params during build and dev
Collects root layout dynamic segments during type generation and emits a typed `declare module 'next/root-params'` declaration to .next/types/root-params.d.ts. The declaration is included via the routes.d.ts entry file when experimental.rootParams (or cacheComponents) is enabled. - simple [param] → Promise<string> - [...param] (catch-all) → Promise<string[]> - [[...param]] (optional catch-all) → Promise<string[] | undefined> - multiple root layouts with the same param name use the most permissive type Also adds a typecheck test that builds the existing root-params fixtures and verifies the generated types match expected return types.
1 parent db39542 commit 7533724

File tree

6 files changed

+266
-0
lines changed

6 files changed

+266
-0
lines changed

packages/next/src/build/index.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ import {
219219
writeValidatorFile,
220220
writeRouteTypesEntryFile,
221221
writeCacheLifeTypesFile,
222+
writeRootParamsTypesFile,
222223
} from '../server/lib/router-utils/route-types-utils'
223224
import { Lockfile } from './lockfile'
224225
import {
@@ -1402,10 +1403,15 @@ export default async function build(
14021403
'types',
14031404
'routes.d.ts'
14041405
)
1406+
const isRootParamsEnabled = Boolean(
1407+
config.experimental.rootParams ?? config.cacheComponents
1408+
)
1409+
14051410
const actualTypesDir = path.join(distDir, 'types')
14061411
await writeRouteTypesEntryFile(entryFilePath, actualTypesDir, {
14071412
strictRouteTypes: Boolean(config.experimental.strictRouteTypes),
14081413
typedRoutes: Boolean(config.typedRoutes),
1414+
rootParams: isRootParamsEnabled,
14091415
})
14101416

14111417
// Generate cache-life types if custom profiles are configured
@@ -1421,6 +1427,19 @@ export default async function build(
14211427
cacheLifeTypesFilePath
14221428
)
14231429
}
1430+
1431+
// Generate root params types if experimental.rootParams (or cacheComponents) is enabled
1432+
if (isRootParamsEnabled) {
1433+
const rootParamsTypesFilePath = path.join(
1434+
distDir,
1435+
'types',
1436+
'root-params.d.ts'
1437+
)
1438+
writeRootParamsTypesFile(
1439+
routeTypesManifest,
1440+
rootParamsTypesFilePath
1441+
)
1442+
}
14241443
})
14251444

14261445
// Turbopack already handles conflicting app and page routes.
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import fs from 'fs'
2+
import path from 'path'
3+
4+
export type RootParamKind = 'dynamic' | 'catchall' | 'optional-catchall'
5+
6+
/**
7+
* Generates TypeScript type definitions for the next/root-params virtual module.
8+
* Creates typed getter functions for each root param found in the app's root layouts.
9+
*/
10+
export function generateRootParamsTypes(
11+
rootParams: Map<string, RootParamKind>
12+
): string {
13+
const entries = Array.from(rootParams.entries()).sort(([a], [b]) =>
14+
a.localeCompare(b)
15+
)
16+
17+
const functions = entries.map(([name, kind]) => {
18+
const returnType =
19+
kind === 'dynamic'
20+
? 'Promise<string>'
21+
: kind === 'catchall'
22+
? 'Promise<string[]>'
23+
: 'Promise<string[] | undefined>'
24+
return ` export function ${name}(): ${returnType}`
25+
})
26+
27+
return `// Type definitions for Next.js root params
28+
29+
declare module 'next/root-params' {
30+
${functions.join('\n')}
31+
}
32+
`
33+
}
34+
35+
/**
36+
* Writes root params type definitions to a file if rootParams exist.
37+
* This is used by both the CLI (next type-gen) and dev server to generate
38+
* root-params.d.ts in the types directory.
39+
*/
40+
export function writeRootParamsTypes(
41+
rootParams: Map<string, RootParamKind> | undefined,
42+
filePath: string
43+
) {
44+
if (!rootParams || rootParams.size === 0) {
45+
return
46+
}
47+
48+
const dirname = path.dirname(filePath)
49+
50+
if (!fs.existsSync(dirname)) {
51+
fs.mkdirSync(dirname, { recursive: true })
52+
}
53+
54+
const content = generateRootParamsTypes(rootParams)
55+
fs.writeFileSync(filePath, content)
56+
}

packages/next/src/server/lib/router-utils/route-types-utils.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ import {
1414
generateRouteTypesFileStrict,
1515
} from './typegen'
1616
import { writeCacheLifeTypes } from './cache-life-type-utils'
17+
import {
18+
writeRootParamsTypes,
19+
type RootParamKind,
20+
} from './root-params-type-utils'
21+
import { getSegmentParam } from '../../../shared/lib/router/utils/get-segment-param'
1722
import { tryToParsePath } from '../../../lib/try-to-parse-path'
1823
import {
1924
extractInterceptionRouteInformation,
@@ -52,6 +57,8 @@ export interface RouteTypesManifest {
5257
filePathToRoute: Map<string, string>
5358
/** Cache life configuration for generating cache-life.d.ts */
5459
cacheLifeConfig?: { [profile: string]: CacheLife }
60+
/** Root params collected from root layouts for generating root-params.d.ts */
61+
rootParams?: Map<string, RootParamKind>
5562
}
5663

5764
// Convert a custom-route source string (`/blog/:slug`, `/docs/:path*`, ...)
@@ -148,6 +155,68 @@ function resolveInterceptingRoute(route: string): string {
148155
}
149156
}
150157

158+
/**
159+
* Identifies root layouts and collects their dynamic segments as root params.
160+
* A root layout is the shallowest layout in each branch of the app directory.
161+
*/
162+
function collectRootParamsFromLayouts(
163+
layoutRoutes: RouteInfo[]
164+
): Map<string, RootParamKind> {
165+
const rootParams = new Map<string, RootParamKind>()
166+
167+
// Sort by depth (fewer path segments = shallower = more likely to be root)
168+
const sorted = [...layoutRoutes].sort(
169+
(a, b) => a.route.split('/').length - b.route.split('/').length
170+
)
171+
172+
const rootLayoutRoutes: string[] = []
173+
174+
for (const { route } of sorted) {
175+
// Skip internal routes
176+
if (
177+
route === UNDERSCORE_GLOBAL_ERROR_ROUTE ||
178+
route === UNDERSCORE_NOT_FOUND_ROUTE
179+
) {
180+
continue
181+
}
182+
183+
// A layout is a root layout if no already-found root layout is an ancestor of it
184+
const hasAncestorRootLayout = rootLayoutRoutes.some((rootRoute) =>
185+
rootRoute === '/' ? route !== '/' : route.startsWith(rootRoute + '/')
186+
)
187+
188+
if (hasAncestorRootLayout) continue
189+
190+
rootLayoutRoutes.push(route)
191+
192+
// Extract dynamic segments from this root layout's route
193+
for (const segment of route.split('/')) {
194+
const param = getSegmentParam(segment)
195+
if (param === null) continue
196+
197+
const kind: RootParamKind =
198+
param.paramType === 'optional-catchall'
199+
? 'optional-catchall'
200+
: param.paramType === 'catchall'
201+
? 'catchall'
202+
: 'dynamic'
203+
204+
// If the same param name appears in multiple root layouts with different
205+
// kinds, keep the most permissive type.
206+
const existing = rootParams.get(param.paramName)
207+
if (
208+
!existing ||
209+
kind === 'optional-catchall' ||
210+
(kind === 'catchall' && existing === 'dynamic')
211+
) {
212+
rootParams.set(param.paramName, kind)
213+
}
214+
}
215+
}
216+
217+
return rootParams
218+
}
219+
151220
/**
152221
* Creates a route types manifest from processed route data
153222
* (used for both build and dev)
@@ -354,6 +423,8 @@ export async function createRouteTypesManifest({
354423
}
355424
}
356425

426+
manifest.rootParams = collectRootParamsFromLayouts(layoutRoutes)
427+
357428
return manifest
358429
}
359430

@@ -417,6 +488,7 @@ export async function writeRouteTypesEntryFile(
417488
options: {
418489
strictRouteTypes: boolean
419490
typedRoutes: boolean
491+
rootParams?: boolean
420492
}
421493
) {
422494
const entryDir = path.dirname(entryFilePath)
@@ -436,6 +508,10 @@ export async function writeRouteTypesEntryFile(
436508
`export type * from "${prefix}route-types.d.ts";`,
437509
]
438510

511+
if (options.rootParams) {
512+
lines.push(`import "${prefix}root-params.d.ts";`)
513+
}
514+
439515
if (options.strictRouteTypes) {
440516
lines.push(`import "${prefix}cache-life.d.ts";`)
441517
lines.push(`import "${prefix}validator.ts";`)
@@ -456,3 +532,10 @@ export async function writeCacheLifeTypesFile(
456532
) {
457533
writeCacheLifeTypes(manifest.cacheLifeConfig, filePath)
458534
}
535+
536+
export function writeRootParamsTypesFile(
537+
manifest: RouteTypesManifest,
538+
filePath: string
539+
) {
540+
writeRootParamsTypes(manifest.rootParams, filePath)
541+
}

packages/next/src/server/lib/router-utils/setup-dev-bundler.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ import {
8888
writeRouteTypesManifest,
8989
writeValidatorFile,
9090
writeRouteTypesEntryFile,
91+
writeRootParamsTypesFile,
9192
} from './route-types-utils'
9293
import { writeCacheLifeTypes } from './cache-life-type-utils'
9394
import {
@@ -1204,6 +1205,18 @@ async function startWatcher(
12041205
const cacheLifeFilePath = path.join(distTypesDir, 'cache-life.d.ts')
12051206
writeCacheLifeTypes(opts.nextConfig.cacheLife, cacheLifeFilePath)
12061207

1208+
// Generate root params types if experimental.rootParams (or cacheComponents) is enabled
1209+
const isRootParamsEnabled = Boolean(
1210+
nextConfig.experimental.rootParams ?? nextConfig.cacheComponents
1211+
)
1212+
if (isRootParamsEnabled) {
1213+
const rootParamsFilePath = path.join(
1214+
distTypesDir,
1215+
'root-params.d.ts'
1216+
)
1217+
writeRootParamsTypesFile(routeTypesManifest, rootParamsFilePath)
1218+
}
1219+
12071220
// Write the entry file at {distDirRoot}/types/routes.d.ts
12081221
// This ensures next-env.d.ts has a consistent import path
12091222
const entryFilePath = path.join(
@@ -1215,6 +1228,7 @@ async function startWatcher(
12151228
await writeRouteTypesEntryFile(entryFilePath, distTypesDir, {
12161229
strictRouteTypes: Boolean(nextConfig.experimental.strictRouteTypes),
12171230
typedRoutes: Boolean(nextConfig.typedRoutes),
1231+
rootParams: isRootParamsEnabled,
12181232
})
12191233
}
12201234

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// This file is type-checked by typecheck.test.ts after building the fixture.
2+
// It verifies that the generated next/root-params types are correct.
3+
import { lang, locale, path } from 'next/root-params'
4+
5+
// lang and locale are simple dynamic segments → Promise<string>
6+
const langResult: Promise<string> = lang()
7+
const localeResult: Promise<string> = locale()
8+
9+
// path appears in both catch-all and optional-catch-all layouts →
10+
// most permissive type wins: Promise<string[] | undefined>
11+
const pathResult: Promise<string[] | undefined> = path()
12+
13+
export { langResult, localeResult, pathResult }
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/* eslint-env jest */
2+
import path from 'path'
3+
import fs from 'fs-extra'
4+
import { nextBuild } from 'next-test-utils'
5+
import execa from 'execa'
6+
7+
const simpleFixtureDir = path.join(__dirname, 'fixtures', 'simple')
8+
const multipleRootsFixtureDir = path.join(
9+
__dirname,
10+
'fixtures',
11+
'multiple-roots'
12+
)
13+
14+
// Turbopack doesn't use this type generation path (handled in Rust)
15+
describe('root params type generation', () => {
16+
describe('simple fixture', () => {
17+
beforeAll(async () => {
18+
await nextBuild(simpleFixtureDir, [], { stderr: true })
19+
})
20+
21+
it('should generate root-params.d.ts with correct types', async () => {
22+
const dts = (
23+
await fs.readFile(
24+
path.join(simpleFixtureDir, '.next', 'types', 'root-params.d.ts')
25+
)
26+
).toString()
27+
28+
// lang and locale are simple dynamic segments → Promise<string>
29+
expect(dts).toContain(`export function lang(): Promise<string>`)
30+
expect(dts).toContain(`export function locale(): Promise<string>`)
31+
32+
// path appears in catch-all and optional-catch-all → most permissive wins
33+
expect(dts).toContain(
34+
`export function path(): Promise<string[] | undefined>`
35+
)
36+
})
37+
38+
it('should include root-params.d.ts import in entry file', async () => {
39+
const entryFile = (
40+
await fs.readFile(
41+
path.join(simpleFixtureDir, '.next', 'types', 'routes.d.ts')
42+
)
43+
).toString()
44+
45+
expect(entryFile).toContain(`import "./root-params.d.ts"`)
46+
})
47+
48+
it('should type-check correctly', async () => {
49+
const result = await execa('tsc', ['--noEmit'], {
50+
cwd: simpleFixtureDir,
51+
reject: false,
52+
})
53+
expect(result.stderr).not.toContain('error TS')
54+
expect(result.stdout).not.toContain('error TS')
55+
})
56+
})
57+
58+
describe('multiple-roots fixture', () => {
59+
beforeAll(async () => {
60+
await nextBuild(multipleRootsFixtureDir, [], { stderr: true })
61+
})
62+
63+
it('should generate root-params.d.ts with correct types', async () => {
64+
const dts = (
65+
await fs.readFile(
66+
path.join(
67+
multipleRootsFixtureDir,
68+
'.next',
69+
'types',
70+
'root-params.d.ts'
71+
)
72+
)
73+
).toString()
74+
75+
// Only the dashboard subtree has a dynamic segment
76+
expect(dts).toContain(`export function id(): Promise<string>`)
77+
// landing layout has no params
78+
expect(dts).not.toContain('lang')
79+
})
80+
})
81+
})

0 commit comments

Comments
 (0)