Skip to content

Commit badf4b6

Browse files
feat(build): add client param parsing support for PPR routes (#82621)
### What? This PR introduces comprehensive type-aware parameter tracking and fallback handling for dynamic routes in PPR scenarios, with enhanced client-side parameter resolution capabilities. ### Why? When using client segment cache with dynamic routes, Next.js faced several critical issues that prevented reliable client-side navigation: **1. Parameter Resolution Inconsistencies**: The previous system had inconsistent behavior when handling parallel route parameters, causing unpredictable parameter resolution across different route segments. This led to client-side navigation failures when encountering dynamic route segments that weren't statically generated. **2. Limited Parameter Type Awareness**: The routing system lacked semantic understanding of different parameter types (dynamic, catchall, optional catchall), making it impossible to implement advanced parameter handling features or provide proper fallback behavior for different parameter scenarios. **3. Incomplete Fallback Parameter Collection**: The original `getFallbackRouteParams()` used simple string-based tracking that couldn't capture the full complexity of parameter relationships, especially in parallel route scenarios where parameters might be defined at different levels of the route tree. **4. Client-Side Parameter Extraction Gaps**: The `getDynamicParam` function had limitations in handling fallback route parameters and lacked proper encoding/decoding mechanisms, leading to potential client-side errors during route resolution. **5. Configuration Validation Missing**: There was no validation to ensure `clientParamParsing` was only enabled when `clientSegmentCache` was also enabled, leading to potential misconfigurations in production. These issues became critical blockers for implementing reliable client-side parameter parsing in PPR scenarios, particularly for the Vercel platform integration (vercel/vercel#13740). ### How? - Introduces `DynamicParamTypes` and `OpaqueFallbackRouteParams` for structured, type-aware parameter tracking - Refactors `getFallbackRouteParams()` to use route modules for comprehensive parameter collection across the routing tree - Adds `resolveParallelRouteParams` function to eliminate parallel route parameter inconsistencies - Enhances `getDynamicParam` with proper fallback parameter handling, encoding, and comprehensive catchall support - Implements configuration validation in `assignDefaultsAndValidate()` to prevent misconfigurations - Adds clientParamParsing boolean to the routes manifest configuration (needed for vercel/vercel#13740) - Updates build pipeline to properly handle enhanced fallback parameters across static generation, export, and rendering phases NAR-305 --------- Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
1 parent 8cd4251 commit badf4b6

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+2264
-268
lines changed

packages/next/errors.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -784,5 +784,12 @@
784784
"783": "Expected document.currentScript to be a <script> element. Received %s instead.",
785785
"784": "Expected document.currentScript src to contain '/_next/'. Received %s instead.",
786786
"785": "Expected webSocket to be defined in dev mode.",
787-
"786": "Expected staticIndicatorState to be defined in dev mode."
787+
"786": "Expected staticIndicatorState to be defined in dev mode.",
788+
"787": "\\`experimental.clientParamParsing\\` can not be \\`true\\` when \\`experimental.clientSegmentCache\\` is \\`false\\`. Client param parsing is only relevant when client segment cache is enabled.",
789+
"788": "Unexpected dynamic param type: %s",
790+
"789": "Expected RSC response, got %s",
791+
"790": "Invariant: Expected RSC response, got %s",
792+
"791": "Unexpected match for a pathname \"%s\" with a param \"%s\" of type \"%s\"",
793+
"792": "Unexpected empty path segments match for a pathname \"%s\" with param \"%s\" of type \"%s\"",
794+
"793": "No value found for segment key: \"%s\""
788795
}

packages/next/src/build/index.ts

Lines changed: 65 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ import {
143143
collectMeta,
144144
} from './utils'
145145
import type { PageInfo, PageInfos } from './utils'
146-
import type { PrerenderedRoute } from './static-paths/types'
146+
import type { FallbackRouteParam, PrerenderedRoute } from './static-paths/types'
147147
import type { AppSegmentConfig } from './segment-config/app/app-segment-config'
148148
import { writeBuildId } from './write-build-id'
149149
import { normalizeLocalePath } from '../shared/lib/i18n/normalize-locale-path'
@@ -323,6 +323,12 @@ export interface DynamicPrerenderManifestRoute {
323323
*/
324324
fallbackRootParams: readonly string[] | undefined
325325

326+
/**
327+
* The fallback route params for this route that were parsed from the loader
328+
* tree.
329+
*/
330+
fallbackRouteParams: readonly FallbackRouteParam[] | undefined
331+
326332
/**
327333
* The source route that this fallback route is based on. This is a reference
328334
* so that we can associate this dynamic route with the correct source.
@@ -461,6 +467,12 @@ export type RoutesManifest = {
461467
prefetchSegmentHeader: typeof NEXT_ROUTER_SEGMENT_PREFETCH_HEADER
462468
prefetchSegmentDirSuffix: typeof RSC_SEGMENTS_DIR_SUFFIX
463469
prefetchSegmentSuffix: typeof RSC_SEGMENT_SUFFIX
470+
471+
/**
472+
* Whether the client param parsing is enabled. This is only relevant for
473+
* app pages when PPR is enabled.
474+
*/
475+
clientParamParsing: boolean
464476
}
465477
rewriteHeaders: {
466478
pathHeader: typeof NEXT_REWRITTEN_PATH_HEADER
@@ -1541,6 +1553,10 @@ export default async function build(
15411553
prefetchSegmentHeader: NEXT_ROUTER_SEGMENT_PREFETCH_HEADER,
15421554
prefetchSegmentSuffix: RSC_SEGMENT_SUFFIX,
15431555
prefetchSegmentDirSuffix: RSC_SEGMENTS_DIR_SUFFIX,
1556+
clientParamParsing:
1557+
// NOTE: once this is the default for `clientSegmentCache`, this
1558+
// should exclusively be based on the `clientSegmentCache` flag.
1559+
config.experimental.clientParamParsing ?? false,
15441560
},
15451561
rewriteHeaders: {
15461562
pathHeader: NEXT_REWRITTEN_PATH_HEADER,
@@ -2886,9 +2902,19 @@ export default async function build(
28862902
// If the route has any dynamic root segments, we need to skip
28872903
// rendering the route. This is because we don't support
28882904
// revalidating the shells without the parameters present.
2905+
// Note that we only have fallback root params if we also have
2906+
// PPR enabled for this route/app already.
28892907
if (
28902908
route.fallbackRootParams &&
2891-
route.fallbackRootParams.length > 0
2909+
route.fallbackRootParams.length > 0 &&
2910+
// We don't skip rendering the route if we have the
2911+
// following enabled. This is because the flight data now
2912+
// does not contain any of the route params and is instead
2913+
// completely static.
2914+
!(
2915+
config.experimental.clientSegmentCache &&
2916+
config.experimental.clientParamParsing
2917+
)
28922918
) {
28932919
return
28942920
}
@@ -3186,12 +3212,25 @@ export default async function build(
31863212
dataRoute = path.posix.join(`${normalizedRoute}${RSC_SUFFIX}`)
31873213
}
31883214

3189-
let prefetchDataRoute: string | null | undefined
3215+
let prefetchDataRoute: string | null = null
31903216
// While we may only write the `.rsc` when the route does not
31913217
// have PPR enabled, we still want to generate the route when
31923218
// deployed so it doesn't 404. If the app has PPR enabled, we
31933219
// should add this key.
3194-
if (!isAppRouteHandler && isAppPPREnabled) {
3220+
if (
3221+
!isAppRouteHandler &&
3222+
isAppPPREnabled &&
3223+
// Don't add a prefetch data route if we have both
3224+
// clientSegmentCache and clientParamParsing enabled. This is
3225+
// because we don't actually use the prefetch data route in
3226+
// this case. This only applies if we have PPR enabled for
3227+
// this route.
3228+
!(
3229+
config.experimental.clientSegmentCache &&
3230+
config.experimental.clientParamParsing &&
3231+
isRoutePPREnabled
3232+
)
3233+
) {
31953234
prefetchDataRoute = path.posix.join(
31963235
`${normalizedRoute}${RSC_PREFETCH_SUFFIX}`
31973236
)
@@ -3264,14 +3303,25 @@ export default async function build(
32643303
dataRoute = path.posix.join(`${normalizedRoute}${RSC_SUFFIX}`)
32653304
}
32663305

3267-
let prefetchDataRoute: string | undefined
3306+
let prefetchDataRoute: string | null = null
32683307
let dynamicRoute = routesManifest.dynamicRoutes.find(
32693308
(r) => r.page === route.pathname
32703309
)
32713310
if (!isAppRouteHandler && isAppPPREnabled) {
3272-
prefetchDataRoute = path.posix.join(
3273-
`${normalizedRoute}${RSC_PREFETCH_SUFFIX}`
3274-
)
3311+
if (
3312+
// Don't add a prefetch data route if we have both
3313+
// clientSegmentCache and clientParamParsing enabled. This is
3314+
// because we don't actually use the prefetch data route in
3315+
// this case. This only applies if we have PPR enabled for
3316+
// this route.
3317+
!config.experimental.clientSegmentCache ||
3318+
!config.experimental.clientParamParsing ||
3319+
!isRoutePPREnabled
3320+
) {
3321+
prefetchDataRoute = path.posix.join(
3322+
`${normalizedRoute}${RSC_PREFETCH_SUFFIX}`
3323+
)
3324+
}
32753325

32763326
// If the dynamic route wasn't found, then we need to create
32773327
// it. This ensures that for each fallback shell there's an
@@ -3416,9 +3466,12 @@ export default async function build(
34163466
fallbackRootParams: fallback
34173467
? route.fallbackRootParams
34183468
: undefined,
3419-
fallbackSourceRoute: route.fallbackRouteParams?.length
3420-
? page
3421-
: undefined,
3469+
fallbackSourceRoute:
3470+
route.fallbackRouteParams &&
3471+
route.fallbackRouteParams.length > 0
3472+
? page
3473+
: undefined,
3474+
fallbackRouteParams: route.fallbackRouteParams,
34223475
dataRouteRegex: !dataRoute
34233476
? null
34243477
: normalizeRouteRegex(
@@ -3945,6 +3998,7 @@ export default async function build(
39453998
fallbackExpire: undefined,
39463999
fallbackSourceRoute: undefined,
39474000
fallbackRootParams: undefined,
4001+
fallbackRouteParams: undefined,
39484002
dataRouteRegex: normalizeRouteRegex(
39494003
getNamedRouteRegex(dataRoute, {
39504004
prefixRouteKeys: true,

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

Lines changed: 91 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ import {
1818
type LoaderTree,
1919
} from '../../../server/lib/app-dir-module'
2020
import { PAGE_SEGMENT_KEY } from '../../../shared/lib/segment'
21-
import type { RouteModule } from '../../../server/route-modules/route-module'
21+
import type { FallbackRouteParam } from '../../static-paths/types'
22+
import { createFallbackRouteParam } from '../../static-paths/utils'
23+
import type { DynamicParamTypes } from '../../../shared/lib/app-router-types'
2224

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

@@ -57,11 +59,18 @@ function attach(segment: AppSegment, userland: unknown, route: string) {
5759

5860
export type AppSegment = {
5961
name: string
60-
param: string | undefined
62+
paramName: string | undefined
63+
paramType: DynamicParamTypes | undefined
6164
filePath: string | undefined
6265
config: AppSegmentConfig | undefined
6366
isDynamicSegment: boolean
6467
generateStaticParams: GenerateStaticParams | undefined
68+
69+
/**
70+
* Whether this segment is a parallel route segment or descends from a
71+
* parallel route segment.
72+
*/
73+
isParallelRouteSegment: boolean | undefined
6574
}
6675

6776
/**
@@ -75,27 +84,33 @@ async function collectAppPageSegments(routeModule: AppPageRouteModule) {
7584
// to see the same segment multiple times.
7685
const uniqueSegments = new Map<string, AppSegment>()
7786

78-
// Queue will store tuples of [loaderTree, currentSegments]
79-
type QueueItem = [LoaderTree, AppSegment[]]
80-
const queue: QueueItem[] = [[routeModule.userland.loaderTree, []]]
87+
// Queue will store tuples of [loaderTree, currentSegments, isParallelRouteSegment]
88+
type QueueItem = [
89+
loaderTree: LoaderTree,
90+
currentSegments: AppSegment[],
91+
isParallelRouteSegment: boolean,
92+
]
93+
const queue: QueueItem[] = [[routeModule.userland.loaderTree, [], false]]
8194

8295
while (queue.length > 0) {
83-
const [loaderTree, currentSegments] = queue.shift()!
96+
const [loaderTree, currentSegments, isParallelRouteSegment] = queue.shift()!
8497
const [name, parallelRoutes] = loaderTree
8598

8699
// Process current node
87100
const { mod: userland, filePath } = await getLayoutOrPageModule(loaderTree)
88101
const isClientComponent = userland && isClientReference(userland)
89102

90-
const param = getSegmentParam(name)?.param
103+
const { param: paramName, type: paramType } = getSegmentParam(name) ?? {}
91104

92105
const segment: AppSegment = {
93106
name,
94-
param,
107+
paramName,
108+
paramType,
95109
filePath,
96110
config: undefined,
97-
isDynamicSegment: !!param,
111+
isDynamicSegment: !!paramName,
98112
generateStaticParams: undefined,
113+
isParallelRouteSegment,
99114
}
100115

101116
// Only server components can have app segment configurations
@@ -123,15 +138,21 @@ async function collectAppPageSegments(routeModule: AppPageRouteModule) {
123138
// Add all parallel routes to the queue
124139
for (const parallelRouteKey in parallelRoutes) {
125140
const parallelRoute = parallelRoutes[parallelRouteKey]
126-
queue.push([parallelRoute, updatedSegments])
141+
queue.push([
142+
parallelRoute,
143+
updatedSegments,
144+
// A parallel route segment is one that descends from a segment that is
145+
// not children or descends from a parallel route segment.
146+
isParallelRouteSegment || parallelRouteKey !== 'children',
147+
])
127148
}
128149
}
129150

130151
return Array.from(uniqueSegments.values())
131152
}
132153

133154
function getSegmentKey(segment: AppSegment) {
134-
return `${segment.name}-${segment.filePath ?? ''}-${segment.param ?? ''}`
155+
return `${segment.name}-${segment.filePath ?? ''}-${segment.paramName ?? ''}`
135156
}
136157

137158
/**
@@ -151,16 +172,18 @@ function collectAppRouteSegments(
151172

152173
// Generate all the segments.
153174
const segments: AppSegment[] = parts.map((name) => {
154-
const param = getSegmentParam(name)?.param
175+
const { param: paramName, type: paramType } = getSegmentParam(name) ?? {}
155176

156177
return {
157178
name,
158-
param,
179+
paramName,
180+
paramType,
159181
filePath: undefined,
160-
isDynamicSegment: !!param,
182+
isDynamicSegment: !!paramName,
161183
config: undefined,
162184
generateStaticParams: undefined,
163-
}
185+
isParallelRouteSegment: undefined,
186+
} satisfies AppSegment
164187
})
165188

166189
// We know we have at least one, we verified this above. We should get the
@@ -182,7 +205,7 @@ function collectAppRouteSegments(
182205
* @returns the segments for the route module
183206
*/
184207
export function collectSegments(
185-
routeModule: RouteModule
208+
routeModule: AppRouteRouteModule | AppPageRouteModule
186209
): Promise<AppSegment[]> | AppSegment[] {
187210
if (isAppRouteRouteModule(routeModule)) {
188211
return collectAppRouteSegments(routeModule)
@@ -196,3 +219,55 @@ export function collectSegments(
196219
'Expected a route module to be one of app route or page'
197220
)
198221
}
222+
223+
/**
224+
* Collects the fallback route params for a given app page route module. This is
225+
* a variant of the `collectSegments` function that only collects the fallback
226+
* route params without importing anything.
227+
*
228+
* @param routeModule the app page route module
229+
* @returns the fallback route params for the app page route module
230+
*/
231+
export function collectFallbackRouteParams(
232+
routeModule: AppPageRouteModule
233+
): readonly FallbackRouteParam[] {
234+
const uniqueSegments = new Map<string, FallbackRouteParam>()
235+
236+
// Queue will store tuples of [loaderTree, isParallelRouteSegment]
237+
type QueueItem = [loaderTree: LoaderTree, isParallelRouteSegment: boolean]
238+
const queue: QueueItem[] = [[routeModule.userland.loaderTree, false]]
239+
240+
while (queue.length > 0) {
241+
const [loaderTree, isParallelRouteSegment] = queue.shift()!
242+
const [name, parallelRoutes] = loaderTree
243+
244+
// Handle this segment (if it's a dynamic segment param).
245+
const segmentParam = getSegmentParam(name)
246+
if (segmentParam) {
247+
const key = `${name}-${segmentParam.param}`
248+
if (!uniqueSegments.has(key)) {
249+
uniqueSegments.set(
250+
key,
251+
createFallbackRouteParam(
252+
segmentParam.param,
253+
segmentParam.type,
254+
isParallelRouteSegment
255+
)
256+
)
257+
}
258+
}
259+
260+
// Add all of this segment's parallel routes to the queue.
261+
for (const parallelRouteKey in parallelRoutes) {
262+
const parallelRoute = parallelRoutes[parallelRouteKey]
263+
queue.push([
264+
parallelRoute,
265+
// A parallel route segment is one that descends from a segment that is
266+
// not children or descends from a parallel route segment.
267+
isParallelRouteSegment || parallelRouteKey !== 'children',
268+
])
269+
}
270+
}
271+
272+
return Array.from(uniqueSegments.values())
273+
}

0 commit comments

Comments
 (0)