Skip to content

Commit 88ebd55

Browse files
committed
[webpack] generate types for next/root-params
1 parent 17dc6cd commit 88ebd55

File tree

3 files changed

+107
-11
lines changed

3 files changed

+107
-11
lines changed

packages/next/errors.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -718,5 +718,6 @@
718718
"717": "\\`unstable_rootParams\\` must not be used within a client component. Next.js should be preventing it from being included in client components statically, but did not in this case.",
719719
"718": "Missing workStore in %s",
720720
"719": "Route %s used %s inside a Route Handler. Support for this API in Route Handlers is planned for a future version of Next.js.",
721-
"720": "%s was used inside a Server Action. This is not supported. Functions from 'next/root-params' can only be called in the context of a route."
721+
"720": "%s was used inside a Server Action. This is not supported. Functions from 'next/root-params' can only be called in the context of a route.",
722+
"721": "Unknown param kind %s"
722723
}

packages/next/src/build/webpack/loaders/next-root-params-loader.ts

Lines changed: 80 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,23 @@ import * as path from 'node:path'
33
import * as fs from 'node:fs/promises'
44
import { normalizeAppPath } from '../../../shared/lib/router/utils/app-paths'
55
import { ensureLeadingSlash } from '../../../shared/lib/page-path/ensure-leading-slash'
6+
import type { DynamicParamTypes } from '../../../server/app-render/types'
67
import { getSegmentParam } from '../../../server/app-render/get-segment-param'
8+
import { InvariantError } from '../../../shared/lib/invariant-error'
79

810
export type RootParamsLoaderOpts = {
911
appDir: string
1012
pageExtensions: string[]
1113
}
1214

13-
type CollectedRootParams = Set<string>
15+
export type CollectedRootParams = Map<string, Set<DynamicParamTypes>>
1416

1517
const rootParamsLoader: webpack.LoaderDefinitionFunction<RootParamsLoaderOpts> =
1618
async function () {
1719
const { appDir, pageExtensions } = this.getOptions()
1820

1921
const allRootParams = await collectRootParamsFromFileSystem({
20-
appDir,
22+
appDir: appDir,
2123
pageExtensions,
2224
// Track every directory we traverse in case a layout gets added to it
2325
// (which would make it the new root layout for that subtree).
@@ -31,7 +33,7 @@ const rootParamsLoader: webpack.LoaderDefinitionFunction<RootParamsLoaderOpts> =
3133
}
3234

3335
// Generate a getter for each root param we found.
34-
const sortedRootParamNames = Array.from(allRootParams).sort()
36+
const sortedRootParamNames = Array.from(allRootParams.keys()).sort()
3537
const content = [
3638
`import { getRootParam } from 'next/dist/server/request/root-params';`,
3739
...sortedRootParamNames.map((paramName) => {
@@ -44,7 +46,7 @@ const rootParamsLoader: webpack.LoaderDefinitionFunction<RootParamsLoaderOpts> =
4446

4547
export default rootParamsLoader
4648

47-
async function collectRootParamsFromFileSystem(
49+
export async function collectRootParamsFromFileSystem(
4850
opts: Parameters<typeof findRootLayouts>[0]
4951
) {
5052
return collectRootParams({
@@ -60,15 +62,22 @@ function collectRootParams({
6062
rootLayoutFilePaths: string[]
6163
appDir: string
6264
}): CollectedRootParams {
63-
const allRootParams: CollectedRootParams = new Set()
65+
// Collect the param names and kinds from all root layouts.
66+
// Note that if multiple root layouts use the same param name, it can have multiple kinds.
67+
const allRootParams: CollectedRootParams = new Map()
6468

6569
for (const rootLayoutFilePath of rootLayoutFilePaths) {
6670
const params = getParamsFromLayoutFilePath({
6771
appDir,
6872
layoutFilePath: rootLayoutFilePath,
6973
})
7074
for (const param of params) {
71-
allRootParams.add(param)
75+
const { param: paramName, type: paramKind } = param
76+
let paramKinds = allRootParams.get(paramName)
77+
if (!paramKinds) {
78+
allRootParams.set(paramName, (paramKinds = new Set()))
79+
}
80+
paramKinds.add(paramKind)
7281
}
7382
}
7483

@@ -149,23 +158,84 @@ async function findRootLayouts({
149158
return visit(appDir)
150159
}
151160

161+
type ParamInfo = { param: string; type: DynamicParamTypes }
162+
152163
function getParamsFromLayoutFilePath({
153164
appDir,
154165
layoutFilePath,
155166
}: {
156167
appDir: string
157168
layoutFilePath: string
158-
}): string[] {
169+
}): ParamInfo[] {
159170
const rootLayoutPath = normalizeAppPath(
160171
ensureLeadingSlash(path.dirname(path.relative(appDir, layoutFilePath)))
161172
)
162173
const segments = rootLayoutPath.split('/')
163-
const paramNames: string[] = []
174+
const params: ParamInfo[] = []
164175
for (const segment of segments) {
165176
const param = getSegmentParam(segment)
166177
if (param !== null) {
167-
paramNames.push(param.param)
178+
params.push(param)
179+
}
180+
}
181+
return params
182+
}
183+
184+
//=============================================
185+
// Type declarations
186+
//=============================================
187+
188+
export function generateDeclarations(rootParams: CollectedRootParams) {
189+
const sortedRootParamNames = Array.from(rootParams.keys()).sort()
190+
const declarationLines = sortedRootParamNames
191+
.map((paramName) => {
192+
// A param can have multiple kinds (in different root layouts).
193+
// In that case, we'll need to union the types together together.
194+
const paramKinds = Array.from(rootParams.get(paramName)!)
195+
const possibleTypesForParam = paramKinds.map((kind) =>
196+
getTypescriptTypeFromParamKind(kind)
197+
)
198+
// A root param getter can be called
199+
// - in a route handler (not yet implemented)
200+
// - a server action (unsupported)
201+
// - in another root layout that doesn't share the same root params.
202+
// For this reason, we currently always want `... | undefined` in the type.
203+
possibleTypesForParam.push(`undefined`)
204+
205+
const paramType = unionTsTypes(possibleTypesForParam)
206+
207+
return [
208+
` /** Allows reading the '${paramName}' root param. */`,
209+
` export function ${paramName}(): Promise<${paramType}>`,
210+
].join('\n')
211+
})
212+
.join('\n\n')
213+
214+
return `declare module 'next/root-params' {\n${declarationLines}\n}\n`
215+
}
216+
217+
function getTypescriptTypeFromParamKind(kind: DynamicParamTypes): string {
218+
switch (kind) {
219+
case 'catchall':
220+
case 'catchall-intercepted': {
221+
return `string[]`
222+
}
223+
case 'optional-catchall': {
224+
return `string[] | undefined`
225+
}
226+
case 'dynamic':
227+
case 'dynamic-intercepted': {
228+
return `string`
229+
}
230+
default: {
231+
kind satisfies never
232+
throw new InvariantError(`Unknown param kind ${kind}`)
168233
}
169234
}
170-
return paramNames
235+
}
236+
237+
function unionTsTypes(types: string[]) {
238+
if (types.length === 0) return 'never'
239+
if (types.length === 1) return types[0]
240+
return types.map((type) => `(${type})`).join(' | ')
171241
}

packages/next/src/build/webpack/plugins/next-types-plugin/index.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ import type { PageExtensions } from '../../../page-extensions-type'
1818
import { devPageFiles } from './shared'
1919
import { getProxiedPluginState } from '../../../build-context'
2020
import type { CacheLife } from '../../../../server/use-cache/cache-life'
21+
import {
22+
collectRootParamsFromFileSystem,
23+
generateDeclarations,
24+
} from '../../loaders/next-root-params-loader'
2125

2226
const PLUGIN_NAME = 'NextTypesPlugin'
2327

@@ -237,6 +241,7 @@ async function collectNamedSlots(layoutPath: string) {
237241
// possible to provide the same experience for dynamic routes.
238242

239243
const pluginState = getProxiedPluginState({
244+
// used for unstable_rootParams()
240245
collectedRootParams: {} as Record<string, string[]>,
241246
routeTypes: {
242247
edge: {
@@ -1021,6 +1026,14 @@ export class NextTypesPlugin {
10211026
pluginState.routeTypes.node.static = ''
10221027
}
10231028

1029+
// We can't rely on webpack's module graph, because in dev we do on-demand compilation,
1030+
// so we'd miss the layouts that haven't been compiled yet
1031+
const rootParamsPromise = collectRootParamsFromFileSystem({
1032+
appDir: this.appDir,
1033+
pageExtensions: this.pageExtensions,
1034+
trackDirectory: undefined,
1035+
})
1036+
10241037
compilation.chunkGroups.forEach((chunkGroup) => {
10251038
chunkGroup.chunks.forEach((chunk) => {
10261039
if (!chunk.name) return
@@ -1059,6 +1072,7 @@ export class NextTypesPlugin {
10591072

10601073
await Promise.all(promises)
10611074

1075+
// unstable_rootParams()
10621076
const rootParams = getRootParamsFromLayouts(
10631077
pluginState.collectedRootParams
10641078
)
@@ -1078,6 +1092,17 @@ export class NextTypesPlugin {
10781092
)
10791093
}
10801094

1095+
// next/root-params
1096+
const collectedRootParams = await rootParamsPromise
1097+
const rootParamsTypesPath = path.join(
1098+
assetDirRelative,
1099+
'types/root-params.d.ts'
1100+
)
1101+
compilation.emitAsset(
1102+
rootParamsTypesPath,
1103+
new sources.RawSource(generateDeclarations(collectedRootParams))
1104+
)
1105+
10811106
// Support `"moduleResolution": "Node16" | "NodeNext"` with `"type": "module"`
10821107

10831108
const packageJsonAssetPath = path.join(

0 commit comments

Comments
 (0)