diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index d7c7cd3ba57a2d..b257c5d4244cdf 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -218,6 +218,7 @@ import { writeRouteTypesManifest, writeValidatorFile, writeRouteTypesEntryFile, + writeRootParamsTypesFile, } from '../server/lib/router-utils/route-types-utils' import { Lockfile } from './lockfile' import { @@ -1401,11 +1402,29 @@ export default async function build( 'types', 'routes.d.ts' ) + const isRootParamsEnabled = Boolean( + config.experimental.rootParams ?? config.cacheComponents + ) + const actualTypesDir = path.join(distDir, 'types') await writeRouteTypesEntryFile(entryFilePath, actualTypesDir, { strictRouteTypes: Boolean(config.experimental.strictRouteTypes), typedRoutes: Boolean(config.typedRoutes), + rootParams: isRootParamsEnabled, }) + + // Generate root params types if experimental.rootParams (or cacheComponents) is enabled + if (isRootParamsEnabled) { + const rootParamsTypesFilePath = path.join( + distDir, + 'types', + 'root-params.d.ts' + ) + writeRootParamsTypesFile( + routeTypesManifest, + rootParamsTypesFilePath + ) + } }) // Turbopack already handles conflicting app and page routes. diff --git a/packages/next/src/server/lib/router-utils/root-params-type-utils.ts b/packages/next/src/server/lib/router-utils/root-params-type-utils.ts new file mode 100644 index 00000000000000..5b77a953f4bcc4 --- /dev/null +++ b/packages/next/src/server/lib/router-utils/root-params-type-utils.ts @@ -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 { + const entries = Array.from(rootParams.entries()).sort(([a], [b]) => + a.localeCompare(b) + ) + + const functions = entries.map(([name, kind]) => { + const returnType = + kind === 'dynamic' + ? 'Promise' + : kind === 'catchall' + ? 'Promise' + : 'Promise' + 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 | undefined, + filePath: string +) { + if (!rootParams || rootParams.size === 0) { + return + } + + const dirname = path.dirname(filePath) + + if (!fs.existsSync(dirname)) { + fs.mkdirSync(dirname, { recursive: true }) + } + + const content = generateRootParamsTypes(rootParams) + fs.writeFileSync(filePath, content) +} diff --git a/packages/next/src/server/lib/router-utils/route-types-utils.ts b/packages/next/src/server/lib/router-utils/route-types-utils.ts index b68aaddefe1607..d04155b6880e4f 100644 --- a/packages/next/src/server/lib/router-utils/route-types-utils.ts +++ b/packages/next/src/server/lib/router-utils/route-types-utils.ts @@ -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 /** Direct mapping from file paths to routes for validation */ filePathToRoute: Map + /** Root params collected from root layouts for generating root-params.d.ts */ + rootParams?: Map } // 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 { + const rootParams = new Map() + + // 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' + + // 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) +} diff --git a/packages/next/src/server/lib/router-utils/setup-dev-bundler.ts b/packages/next/src/server/lib/router-utils/setup-dev-bundler.ts index 9935290f70f429..9ad1752f158832 100644 --- a/packages/next/src/server/lib/router-utils/setup-dev-bundler.ts +++ b/packages/next/src/server/lib/router-utils/setup-dev-bundler.ts @@ -88,6 +88,7 @@ import { writeRouteTypesManifest, writeValidatorFile, writeRouteTypesEntryFile, + writeRootParamsTypesFile, } from './route-types-utils' import { writeCacheLifeTypes } from './cache-life-type-utils' import { @@ -1204,6 +1205,18 @@ async function startWatcher( const cacheLifeFilePath = path.join(distTypesDir, 'cache-life.d.ts') writeCacheLifeTypes(opts.nextConfig.cacheLife, cacheLifeFilePath) + // Generate root params types if experimental.rootParams (or cacheComponents) is enabled + const isRootParamsEnabled = Boolean( + nextConfig.experimental.rootParams ?? nextConfig.cacheComponents + ) + if (isRootParamsEnabled) { + const rootParamsFilePath = path.join( + distTypesDir, + 'root-params.d.ts' + ) + writeRootParamsTypesFile(routeTypesManifest, rootParamsFilePath) + } + // Write the entry file at {distDirRoot}/types/routes.d.ts // This ensures next-env.d.ts has a consistent import path const entryFilePath = path.join( @@ -1215,6 +1228,7 @@ async function startWatcher( await writeRouteTypesEntryFile(entryFilePath, distTypesDir, { strictRouteTypes: Boolean(nextConfig.experimental.strictRouteTypes), typedRoutes: Boolean(nextConfig.typedRoutes), + rootParams: isRootParamsEnabled, }) } diff --git a/test/e2e/app-dir/app-root-params-getters/fixtures/simple/type-tests.ts b/test/e2e/app-dir/app-root-params-getters/fixtures/simple/type-tests.ts new file mode 100644 index 00000000000000..8a498164d5a08a --- /dev/null +++ b/test/e2e/app-dir/app-root-params-getters/fixtures/simple/type-tests.ts @@ -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 +const langResult: Promise = lang() +const localeResult: Promise = locale() + +// path appears in both catch-all and optional-catch-all layouts → +// most permissive type wins: Promise +const pathResult: Promise = path() + +export { langResult, localeResult, pathResult } diff --git a/test/e2e/app-dir/app-root-params-getters/typecheck.test.ts b/test/e2e/app-dir/app-root-params-getters/typecheck.test.ts new file mode 100644 index 00000000000000..b18f349d4514cb --- /dev/null +++ b/test/e2e/app-dir/app-root-params-getters/typecheck.test.ts @@ -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 + expect(dts).toContain(`export function lang(): Promise`) + expect(dts).toContain(`export function locale(): Promise`) + + // path appears in catch-all and optional-catch-all → most permissive wins + expect(dts).toContain( + `export function path(): Promise` + ) + }) + + 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`) + // landing layout has no params + expect(dts).not.toContain('lang') + }) + }) +})