Skip to content

Commit 6cecc75

Browse files
authored
Fix dynamic catchall parameter interpolation in parallel routes (#84279)
### Fixing a bug - Tests added with comprehensive e2e test suite for parallel route navigations - Error handling improved with clearer error messages for missing dynamic parameters ### What? Fixes incorrect parameter interpolation where catchall parameters in parallel routes included unintended prefix segments. ### Why? In parallel routes with dynamic catchall parameters, the parameter interpolation was incorrectly including prefix segments from the route path. For example, with a route structure like `/[teamSlug]/@slot/[...catchAll]`, a request to `/vercel/test/path` would incorrectly set `catchAll = ["vercel", "test", "path"]` instead of the correct `catchAll = ["test", "path"]`. This bug affected the accuracy of dynamic parameter values in parallel routes, leading to incorrect behavior in applications relying on these parameters for routing logic. ### How? - Introduced `interpolateParallelRouteParams()` function that properly handles path depth traversal for complex route structures - Refactored parameter interpolation to occur earlier in the render process, ensuring consistency across both postponed and regular rendering scenarios - Updated the `getDynamicParam()` function to use pre-interpolated parameters instead of performing inline path parsing - Added comprehensive e2e tests to cover various parallel route navigation scenarios and parameter interpolation edge cases - Improved error handling with more descriptive error messages for missing dynamic parameters The fix ensures that dynamic parameters are correctly extracted based on their position in the route hierarchy, respecting the boundaries defined by parallel route slots. NAR-335
1 parent 8f6155f commit 6cecc75

File tree

23 files changed

+514
-452
lines changed

23 files changed

+514
-452
lines changed

packages/next/errors.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -861,5 +861,6 @@
861861
"860": "Client Max Body Size must be a valid number (bytes) or filesize format string (e.g., \"5mb\")",
862862
"861": "Client Max Body Size must be larger than 0 bytes",
863863
"862": "Request body exceeded %s",
864-
"863": "\\`<Link legacyBehavior>\\` received a direct child that is either a Server Component, or JSX that was loaded with React.lazy(). This is not supported. Either remove legacyBehavior, or make the direct child a Client Component that renders the Link's \\`<a>\\` tag."
864+
"863": "\\`<Link legacyBehavior>\\` received a direct child that is either a Server Component, or JSX that was loaded with React.lazy(). This is not supported. Either remove legacyBehavior, or make the direct child a Client Component that renders the Link's \\`<a>\\` tag.",
865+
"864": "Missing value for segment key: \"%s\" with dynamic param type: %s"
865866
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
isAppPageRouteModule,
1313
} from '../../../server/route-modules/checks'
1414
import { isClientReference } from '../../../lib/client-and-server-references'
15-
import { getSegmentParam } from '../../../server/app-render/get-segment-param'
15+
import { getSegmentParam } from '../../../shared/lib/router/utils/get-segment-param'
1616
import {
1717
getLayoutOrPageModule,
1818
type LoaderTree,

packages/next/src/build/segment-config/app/collect-root-param-keys.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { getSegmentParam } from '../../../server/app-render/get-segment-param'
1+
import { getSegmentParam } from '../../../shared/lib/router/utils/get-segment-param'
22
import type AppPageRouteModule from '../../../server/route-modules/app-page/module'
33
import {
44
isAppPageRouteModule,

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ 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 { getSegmentParam } from '../../../server/app-render/get-segment-param'
6+
import { getSegmentParam } from '../../../shared/lib/router/utils/get-segment-param'
77

88
export type RootParamsLoaderOpts = {
99
appDir: string

packages/next/src/server/app-render/app-render.tsx

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ import {
8686
getDigestForWellKnownError,
8787
} from './create-error-handler'
8888
import { dynamicParamTypes } from './get-short-dynamic-param-type'
89-
import { getSegmentParam } from './get-segment-param'
89+
import { getSegmentParam } from '../../shared/lib/router/utils/get-segment-param'
9090
import { getScriptNonceFromHeader } from './get-script-nonce-from-header'
9191
import { parseAndValidateFlightRouterState } from './parse-and-validate-flight-router-state'
9292
import { createFlightRouterStateFromLoaderTree } from './create-flight-router-state-from-loader-tree'
@@ -181,7 +181,7 @@ import { InvariantError } from '../../shared/lib/invariant-error'
181181

182182
import { HTML_CONTENT_TYPE_HEADER, INFINITE_CACHE } from '../../lib/constants'
183183
import { createComponentStylesAndScripts } from './create-component-styles-and-scripts'
184-
import { parseLoaderTree } from './parse-loader-tree'
184+
import { parseLoaderTree } from '../../shared/lib/router/utils/parse-loader-tree'
185185
import {
186186
createPrerenderResumeDataCache,
187187
createRenderResumeDataCache,
@@ -202,7 +202,10 @@ import { isReactLargeShellError } from './react-large-shell-error'
202202
import type { GlobalErrorComponent } from '../../client/components/builtin/global-error'
203203
import { normalizeConventionFilePath } from './segment-explorer-path'
204204
import { getRequestMeta } from '../request-meta'
205-
import { getDynamicParam } from '../../shared/lib/router/utils/get-dynamic-param'
205+
import {
206+
getDynamicParam,
207+
interpolateParallelRouteParams,
208+
} from '../../shared/lib/router/utils/get-dynamic-param'
206209
import type { ExperimentalConfig } from '../config-shared'
207210
import type { Params } from '../request/params'
208211
import { createPromiseWithResolvers } from '../../shared/lib/promise-with-resolvers'
@@ -394,8 +397,7 @@ function createNotFoundLoaderTree(loaderTree: LoaderTree): LoaderTree {
394397
* Returns a function that parses the dynamic segment and return the associated value.
395398
*/
396399
function makeGetDynamicParamFromSegment(
397-
params: { [key: string]: any },
398-
pagePath: string,
400+
interpolatedParams: Params,
399401
fallbackRouteParams: OpaqueFallbackRouteParams | null
400402
): GetDynamicParamFromSegment {
401403
return function getDynamicParamFromSegment(
@@ -409,10 +411,9 @@ function makeGetDynamicParamFromSegment(
409411
const segmentKey = segmentParam.param
410412
const dynamicParamType = dynamicParamTypes[segmentParam.type]
411413
return getDynamicParam(
412-
params,
414+
interpolatedParams,
413415
segmentKey,
414416
dynamicParamType,
415-
pagePath,
416417
fallbackRouteParams
417418
)
418419
}
@@ -1481,6 +1482,7 @@ async function renderToHTMLOrFlightImpl(
14811482
postponedState: PostponedState | null,
14821483
serverComponentsHmrCache: ServerComponentsHmrCache | undefined,
14831484
sharedContext: AppSharedContext,
1485+
interpolatedParams: Params,
14841486
fallbackRouteParams: OpaqueFallbackRouteParams | null
14851487
) {
14861488
const isNotFoundPath = pagePath === '/404'
@@ -1695,14 +1697,8 @@ async function renderToHTMLOrFlightImpl(
16951697
// HTML request ID.
16961698
htmlRequestId = parsedRequestHeaders.htmlRequestId || requestId
16971699

1698-
/**
1699-
* Dynamic parameters. E.g. when you visit `/dashboard/vercel` which is rendered by `/dashboard/[slug]` the value will be {"slug": "vercel"}.
1700-
*/
1701-
const params = renderOpts.params ?? {}
1702-
17031700
const getDynamicParamFromSegment = makeGetDynamicParamFromSegment(
1704-
params,
1705-
pagePath,
1701+
interpolatedParams,
17061702
fallbackRouteParams
17071703
)
17081704

@@ -2032,6 +2028,7 @@ export const renderToHTMLOrFlight: AppPageRender = (
20322028
const { isPrefetchRequest, previouslyRevalidatedTags, nonce } =
20332029
parsedRequestHeaders
20342030

2031+
let interpolatedParams: Params
20352032
let postponedState: PostponedState | null = null
20362033

20372034
// If provided, the postpone state should be parsed so it can be provided to
@@ -2043,10 +2040,23 @@ export const renderToHTMLOrFlight: AppPageRender = (
20432040
)
20442041
}
20452042

2043+
interpolatedParams = interpolateParallelRouteParams(
2044+
renderOpts.ComponentMod.routeModule.userland.loaderTree,
2045+
renderOpts.params ?? {},
2046+
pagePath,
2047+
fallbackRouteParams
2048+
)
2049+
20462050
postponedState = parsePostponedState(
20472051
renderOpts.postponed,
2052+
interpolatedParams
2053+
)
2054+
} else {
2055+
interpolatedParams = interpolateParallelRouteParams(
2056+
renderOpts.ComponentMod.routeModule.userland.loaderTree,
2057+
renderOpts.params ?? {},
20482058
pagePath,
2049-
renderOpts.params
2059+
fallbackRouteParams
20502060
)
20512061
}
20522062

@@ -2085,6 +2095,7 @@ export const renderToHTMLOrFlight: AppPageRender = (
20852095
postponedState,
20862096
serverComponentsHmrCache,
20872097
sharedContext,
2098+
interpolatedParams,
20882099
fallbackRouteParams
20892100
)
20902101
}

packages/next/src/server/app-render/create-component-tree.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
import { getLayoutOrPageModule } from '../lib/app-dir-module'
1212
import type { LoaderTree } from '../lib/app-dir-module'
1313
import { interopDefault } from './interop-default'
14-
import { parseLoaderTree } from './parse-loader-tree'
14+
import { parseLoaderTree } from '../../shared/lib/router/utils/parse-loader-tree'
1515
import type { AppRenderContext, GetDynamicParamFromSegment } from './app-render'
1616
import { createComponentStylesAndScripts } from './create-component-styles-and-scripts'
1717
import { getLayerAssets } from './get-layer-assets'

packages/next/src/server/app-render/postponed-state.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ describe('getDynamicHTMLPostponedState', () => {
5252
isCacheComponentsEnabled
5353
)
5454

55-
const parsed = parsePostponedState(state, '/blog/[slug]', { slug: '123' })
55+
const parsed = parsePostponedState(state, { slug: '123' })
5656

5757
expect(parsed).toMatchInlineSnapshot(`
5858
{
@@ -110,7 +110,7 @@ describe('getDynamicHTMLPostponedState', () => {
110110

111111
const value = 'hello'
112112
const params = { slug: value }
113-
const parsed = parsePostponedState(state, '/blog/[slug]', params)
113+
const parsed = parsePostponedState(state, params)
114114
expect(parsed).toEqual({
115115
type: DynamicState.HTML,
116116
data: [1, { [value]: value }],
@@ -138,7 +138,7 @@ describe('parsePostponedState', () => {
138138
const params = {
139139
slug: Math.random().toString(16).slice(3),
140140
}
141-
const parsed = parsePostponedState(state, '/blog/[slug]', params)
141+
const parsed = parsePostponedState(state, params)
142142

143143
// Ensure that it parsed it correctly.
144144
expect(parsed).toEqual({
@@ -154,7 +154,7 @@ describe('parsePostponedState', () => {
154154
it('parses a HTML postponed state without fallback params', () => {
155155
const state = `2:{}null`
156156
const params = {}
157-
const parsed = parsePostponedState(state, '/blog', params)
157+
const parsed = parsePostponedState(state, params)
158158

159159
// Ensure that it parsed it correctly.
160160
expect(parsed).toEqual({
@@ -166,7 +166,7 @@ describe('parsePostponedState', () => {
166166

167167
it('parses a data postponed state', () => {
168168
const state = '4:nullnull'
169-
const parsed = parsePostponedState(state, '/blog', undefined)
169+
const parsed = parsePostponedState(state, {})
170170

171171
// Ensure that it parsed it correctly.
172172
expect(parsed).toEqual({

packages/next/src/server/app-render/postponed-state.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,7 @@ export async function getDynamicDataPostponedState(
116116

117117
export function parsePostponedState(
118118
state: string,
119-
pagePath: string,
120-
params: Params | undefined
119+
interpolatedParams: Params
121120
): PostponedState {
122121
try {
123122
const postponedStringLengthMatch = state.match(/^([0-9]*):/)?.[1]
@@ -162,7 +161,10 @@ export function parsePostponedState(
162161
) as OpaqueFallbackRouteParamEntries
163162

164163
let postponed = postponedString.slice(match.length + length)
165-
for (const [key, [searchValue, dynamicParamType]] of replacements) {
164+
for (const [
165+
segmentKey,
166+
[searchValue, dynamicParamType],
167+
] of replacements) {
166168
const {
167169
treeSegment: [
168170
,
@@ -172,10 +174,9 @@ export function parsePostponedState(
172174
value,
173175
],
174176
} = getDynamicParam(
175-
params ?? {},
176-
key,
177+
interpolatedParams,
178+
segmentKey,
177179
dynamicParamType,
178-
pagePath,
179180
null
180181
)
181182

packages/next/src/server/app-render/walk-tree-with-flight-router-state.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import type { AppRenderContext } from './app-render'
1818
import { hasLoadingComponentInTree } from './has-loading-component-in-tree'
1919
import { addSearchParamsIfPageSegment } from '../../shared/lib/segment'
2020
import { createComponentTree } from './create-component-tree'
21-
import { getSegmentParam } from './get-segment-param'
21+
import { getSegmentParam } from '../../shared/lib/router/utils/get-segment-param'
2222

2323
/**
2424
* Use router state to decide at what common layout to render the page.

0 commit comments

Comments
 (0)