Skip to content

Commit d503134

Browse files
committed
feat(app-router): add comprehensive fallback parameter collection and validation system
Introduces new fallback parameter collection mechanism spanning build-time static path generation, runtime parameter resolution, and client-side parsing. Adds FallbackRouteParam types, collectFallbackRouteParams functionality, enhanced parameter validation in app segments, and improved error handling for dynamic route parameters across the entire App Router pipeline.
1 parent 1eae68a commit d503134

File tree

26 files changed

+467
-144
lines changed

26 files changed

+467
-144
lines changed

packages/next/errors.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -779,5 +779,6 @@
779779
"778": "`prerenderAndAbortInSequentialTasksWithStages` should not be called in edge runtime.",
780780
"779": "Route %s used \"searchParams\" inside \"use cache\". Accessing dynamic request data inside a cache scope is not supported. If you need some search params inside a cached function await \"searchParams\" outside of the cached function and pass only the required search params as arguments to the cached function. See more info here: https://nextjs.org/docs/messages/next-request-in-use-cache",
781781
"780": "Invariant: failed to find parent dynamic route for notFound route %s",
782-
"781": "\\`experimental.clientParamParsing\\` can not be \\`true\\` when \\`experimental.clientSegmentCache\\` is \\`false\\`. Client param parsing is only relevant when client segment cache is enabled."
782+
"781": "\\`experimental.clientParamParsing\\` can not be \\`true\\` when \\`experimental.clientSegmentCache\\` is \\`false\\`. Client param parsing is only relevant when client segment cache is enabled.",
783+
"782": "Unexpected dynamic param type: %s"
783784
}

packages/next/src/build/index.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ import {
140140
collectMeta,
141141
} from './utils'
142142
import type { PageInfo, PageInfos } from './utils'
143-
import type { PrerenderedRoute } from './static-paths/types'
143+
import type { FallbackRouteParam, PrerenderedRoute } from './static-paths/types'
144144
import type { AppSegmentConfig } from './segment-config/app/app-segment-config'
145145
import { writeBuildId } from './write-build-id'
146146
import { normalizeLocalePath } from '../shared/lib/i18n/normalize-locale-path'
@@ -320,6 +320,12 @@ export interface DynamicPrerenderManifestRoute {
320320
*/
321321
fallbackRootParams: readonly string[] | undefined
322322

323+
/**
324+
* The fallback route params for this route that were parsed from the loader
325+
* tree.
326+
*/
327+
fallbackRouteParams: readonly FallbackRouteParam[] | undefined
328+
323329
/**
324330
* The source route that this fallback route is based on. This is a reference
325331
* so that we can associate this dynamic route with the correct source.
@@ -3100,7 +3106,12 @@ export default async function build(
31003106
for (const prerenderedRoute of prerenderedRoutes) {
31013107
if (
31023108
prerenderedRoute.fallbackRouteParams &&
3103-
prerenderedRoute.fallbackRouteParams.length > 0
3109+
prerenderedRoute.fallbackRouteParams.length > 0 &&
3110+
// If all the fallback route params are parallel route params,
3111+
// then we still consider it a known route.
3112+
!prerenderedRoute.fallbackRouteParams.every(
3113+
(param) => param.isParallelRouteParam
3114+
)
31043115
) {
31053116
unsortedUnknownPrerenderRoutes.push(prerenderedRoute)
31063117
} else {
@@ -3126,7 +3137,12 @@ export default async function build(
31263137
if (
31273138
isRoutePPREnabled &&
31283139
prerenderedRoute.fallbackRouteParams &&
3129-
prerenderedRoute.fallbackRouteParams.length > 0
3140+
prerenderedRoute.fallbackRouteParams.length > 0 &&
3141+
// If all the fallback route params are parallel route params,
3142+
// then we still consider it a static route.
3143+
!prerenderedRoute.fallbackRouteParams.every(
3144+
(param) => param.isParallelRouteParam
3145+
)
31303146
) {
31313147
// If the route has unknown params, then we need to add it to
31323148
// the list of dynamic routes.
@@ -3432,9 +3448,17 @@ export default async function build(
34323448
fallbackRootParams: fallback
34333449
? route.fallbackRootParams
34343450
: undefined,
3435-
fallbackSourceRoute: route.fallbackRouteParams?.length
3436-
? page
3437-
: undefined,
3451+
fallbackSourceRoute:
3452+
route.fallbackRouteParams &&
3453+
route.fallbackRouteParams.length > 0 &&
3454+
// If all the fallback route params are parallel route
3455+
// params, then we still consider it a static route.
3456+
!route.fallbackRouteParams.every(
3457+
(param) => param.isParallelRouteParam
3458+
)
3459+
? page
3460+
: undefined,
3461+
fallbackRouteParams: route.fallbackRouteParams,
34383462
dataRouteRegex: !dataRoute
34393463
? null
34403464
: normalizeRouteRegex(
@@ -3901,6 +3925,7 @@ export default async function build(
39013925
fallbackExpire: undefined,
39023926
fallbackSourceRoute: undefined,
39033927
fallbackRootParams: undefined,
3928+
fallbackRouteParams: undefined,
39043929
dataRouteRegex: normalizeRouteRegex(
39053930
getNamedRouteRegex(dataRoute, {
39063931
prefixRouteKeys: true,

packages/next/src/build/segment-config/app/app-segments.ts

Lines changed: 87 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,6 @@
1-
import type { LoadComponentsReturnType } from '../../../server/load-components'
21
import type { Params } from '../../../server/request/params'
3-
import type {
4-
AppPageRouteModule,
5-
AppPageModule,
6-
} from '../../../server/route-modules/app-page/module.compiled'
7-
import type {
8-
AppRouteRouteModule,
9-
AppRouteModule,
10-
} from '../../../server/route-modules/app-route/module.compiled'
2+
import type { AppPageRouteModule } from '../../../server/route-modules/app-page/module.compiled'
3+
import type { AppRouteRouteModule } from '../../../server/route-modules/app-route/module.compiled'
114
import {
125
type AppSegmentConfig,
136
parseAppSegmentConfig,
@@ -25,6 +18,8 @@ import {
2518
type LoaderTree,
2619
} from '../../../server/lib/app-dir-module'
2720
import { PAGE_SEGMENT_KEY } from '../../../shared/lib/segment'
21+
import type { FallbackRouteParam } from '../../static-paths/types'
22+
import { createFallbackRouteParam } from '../../static-paths/utils'
2823

2924
type GenerateStaticParams = (options: { params?: Params }) => Promise<Params[]>
3025

@@ -63,11 +58,17 @@ function attach(segment: AppSegment, userland: unknown, route: string) {
6358

6459
export type AppSegment = {
6560
name: string
66-
param: string | undefined
61+
paramName: string | undefined
6762
filePath: string | undefined
6863
config: AppSegmentConfig | undefined
6964
isDynamicSegment: boolean
7065
generateStaticParams: GenerateStaticParams | undefined
66+
67+
/**
68+
* Whether this segment is a parallel route segment or descends from a
69+
* parallel route segment.
70+
*/
71+
isParallelRouteSegment: boolean | undefined
7172
}
7273

7374
/**
@@ -81,27 +82,32 @@ async function collectAppPageSegments(routeModule: AppPageRouteModule) {
8182
// to see the same segment multiple times.
8283
const uniqueSegments = new Map<string, AppSegment>()
8384

84-
// Queue will store tuples of [loaderTree, currentSegments]
85-
type QueueItem = [LoaderTree, AppSegment[]]
86-
const queue: QueueItem[] = [[routeModule.userland.loaderTree, []]]
85+
// Queue will store tuples of [loaderTree, currentSegments, isParallelRouteSegment]
86+
type QueueItem = [
87+
loaderTree: LoaderTree,
88+
currentSegments: AppSegment[],
89+
isParallelRouteSegment: boolean,
90+
]
91+
const queue: QueueItem[] = [[routeModule.userland.loaderTree, [], false]]
8792

8893
while (queue.length > 0) {
89-
const [loaderTree, currentSegments] = queue.shift()!
94+
const [loaderTree, currentSegments, isParallelRouteSegment] = queue.shift()!
9095
const [name, parallelRoutes] = loaderTree
9196

9297
// Process current node
9398
const { mod: userland, filePath } = await getLayoutOrPageModule(loaderTree)
9499
const isClientComponent = userland && isClientReference(userland)
95100

96-
const param = getSegmentParam(name)?.param
101+
const paramName = getSegmentParam(name)?.param
97102

98103
const segment: AppSegment = {
99104
name,
100-
param,
105+
paramName,
101106
filePath,
102107
config: undefined,
103-
isDynamicSegment: !!param,
108+
isDynamicSegment: !!paramName,
104109
generateStaticParams: undefined,
110+
isParallelRouteSegment,
105111
}
106112

107113
// Only server components can have app segment configurations
@@ -129,15 +135,21 @@ async function collectAppPageSegments(routeModule: AppPageRouteModule) {
129135
// Add all parallel routes to the queue
130136
for (const parallelRouteKey in parallelRoutes) {
131137
const parallelRoute = parallelRoutes[parallelRouteKey]
132-
queue.push([parallelRoute, updatedSegments])
138+
queue.push([
139+
parallelRoute,
140+
updatedSegments,
141+
// A parallel route segment is one that descends from a segment that is
142+
// not children or descends from a parallel route segment.
143+
isParallelRouteSegment || parallelRouteKey !== 'children',
144+
])
133145
}
134146
}
135147

136148
return Array.from(uniqueSegments.values())
137149
}
138150

139151
function getSegmentKey(segment: AppSegment) {
140-
return `${segment.name}-${segment.filePath ?? ''}-${segment.param ?? ''}`
152+
return `${segment.name}-${segment.filePath ?? ''}-${segment.paramName ?? ''}`
141153
}
142154

143155
/**
@@ -157,16 +169,17 @@ function collectAppRouteSegments(
157169

158170
// Generate all the segments.
159171
const segments: AppSegment[] = parts.map((name) => {
160-
const param = getSegmentParam(name)?.param
172+
const paramName = getSegmentParam(name)?.param
161173

162174
return {
163175
name,
164-
param,
176+
paramName,
165177
filePath: undefined,
166-
isDynamicSegment: !!param,
178+
isDynamicSegment: !!paramName,
167179
config: undefined,
168180
generateStaticParams: undefined,
169-
}
181+
isParallelRouteSegment: undefined,
182+
} satisfies AppSegment
170183
})
171184

172185
// We know we have at least one, we verified this above. We should get the
@@ -187,11 +200,9 @@ function collectAppRouteSegments(
187200
* @param components the loaded components
188201
* @returns the segments for the route module
189202
*/
190-
export function collectSegments({
191-
routeModule,
192-
}: LoadComponentsReturnType<AppPageModule | AppRouteModule>):
193-
| Promise<AppSegment[]>
194-
| AppSegment[] {
203+
export function collectSegments(
204+
routeModule: AppRouteRouteModule | AppPageRouteModule
205+
): Promise<AppSegment[]> | AppSegment[] {
195206
if (isAppRouteRouteModule(routeModule)) {
196207
return collectAppRouteSegments(routeModule)
197208
}
@@ -204,3 +215,51 @@ export function collectSegments({
204215
'Expected a route module to be one of app route or page'
205216
)
206217
}
218+
219+
/**
220+
* Collects the fallback route params for a given app page route module. This is
221+
* a variant of the `collectSegments` function that only collects the fallback
222+
* route params without importing anything.
223+
*
224+
* @param routeModule the app page route module
225+
* @returns the fallback route params for the app page route module
226+
*/
227+
export function collectFallbackRouteParams(
228+
routeModule: AppPageRouteModule
229+
): readonly FallbackRouteParam[] {
230+
const uniqueSegments = new Map<string, FallbackRouteParam>()
231+
232+
// Queue will store tuples of [loaderTree, isParallelRouteSegment]
233+
type QueueItem = [loaderTree: LoaderTree, isParallelRouteSegment: boolean]
234+
const queue: QueueItem[] = [[routeModule.userland.loaderTree, false]]
235+
236+
while (queue.length > 0) {
237+
const [loaderTree, isParallelRouteSegment] = queue.shift()!
238+
const [name, parallelRoutes] = loaderTree
239+
240+
// Handle this segment (if it's a dynamic segment param).
241+
const segmentParam = getSegmentParam(name)
242+
if (segmentParam) {
243+
const key = `${name}-${segmentParam.param}`
244+
if (!uniqueSegments.has(key)) {
245+
uniqueSegments.set(
246+
key,
247+
createFallbackRouteParam(segmentParam.param, isParallelRouteSegment)
248+
)
249+
}
250+
}
251+
252+
// Add all of this segment's parallel routes to the queue.
253+
for (const parallelRouteKey in parallelRoutes) {
254+
const parallelRoute = parallelRoutes[parallelRouteKey]
255+
queue.push([
256+
parallelRoute,
257+
// A parallel route segment is one that descends from a segment that is
258+
// not children or descends from a parallel route segment.
259+
isParallelRouteSegment || parallelRouteKey !== 'children',
260+
])
261+
}
262+
}
263+
264+
return Array.from(uniqueSegments.values())
265+
}

0 commit comments

Comments
 (0)