Skip to content

Commit 1337c7a

Browse files
authored
fix dynamic param extraction for interception routes (#67400)
### What When using `generateStaticParams` with interception routes, the interception would never occur, and instead an MPA navigation would take place to the targeted link. ### Why For interception rewrites, we use a `__NEXT_EMPTY_PARAM__` marker (in place of the actual param slot, eg `:locale`) for any params that are discovered prior to the interception marker. This is because during route resolution, the `params` for the interception route might not contain the same `params` for the page that triggered the interception. The dynamic params are then extracted from `FlightRouterState` at render time. However, when `generateStaticParams` is present, the `FlightRouterState` header is stripped from the request, so it isn't able to extract the dynamic params and so the router thinks the new tree is a new root layout, hence the MPA navigation. ### How This removes the `__NEXT_EMPTY_PARAM__` hack and several spots where we were forcing interception routes to be dynamic as a workaround to the above bug. Now when resolving the route, if the request was to an interception route, we extract the dynamic params from the request before constructing the final rewritten URL. This will ensure that the params from the "current" route are available in addition to the params from the interception route without needing to defer until render. Fixes #65192 Fixes #52880
1 parent 13e501f commit 1337c7a

File tree

18 files changed

+225
-198
lines changed

18 files changed

+225
-198
lines changed

packages/next/src/build/index.ts

Lines changed: 13 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,6 @@ import {
166166
import { getStartServerInfo, logStartInfo } from '../server/lib/app-info-log'
167167
import type { NextEnabledDirectories } from '../server/base-server'
168168
import { hasCustomExportOutput } from '../export/utils'
169-
import { isInterceptionRouteAppPath } from '../server/lib/interception-routes'
170169
import {
171170
getTurbopackJsConfig,
172171
handleEntrypoints,
@@ -2106,8 +2105,6 @@ export default async function build(
21062105
}
21072106

21082107
const appConfig = workerResult.appConfig || {}
2109-
const isInterceptionRoute =
2110-
isInterceptionRouteAppPath(page)
21112108
if (appConfig.revalidate !== 0) {
21122109
const isDynamic = isDynamicRoute(page)
21132110
const hasGenerateStaticParams =
@@ -2124,27 +2121,22 @@ export default async function build(
21242121
}
21252122

21262123
// Mark the app as static if:
2127-
// - It's not an interception route (these currently depend on request headers and cannot be computed at build)
21282124
// - It has no dynamic param
21292125
// - It doesn't have generateStaticParams but `dynamic` is set to
21302126
// `error` or `force-static`
2131-
if (!isInterceptionRoute) {
2132-
if (!isDynamic) {
2133-
appStaticPaths.set(originalAppPath, [page])
2134-
appStaticPathsEncoded.set(originalAppPath, [
2135-
page,
2136-
])
2137-
isStatic = true
2138-
} else if (
2139-
!hasGenerateStaticParams &&
2140-
(appConfig.dynamic === 'error' ||
2141-
appConfig.dynamic === 'force-static')
2142-
) {
2143-
appStaticPaths.set(originalAppPath, [])
2144-
appStaticPathsEncoded.set(originalAppPath, [])
2145-
isStatic = true
2146-
isRoutePPREnabled = false
2147-
}
2127+
if (!isDynamic) {
2128+
appStaticPaths.set(originalAppPath, [page])
2129+
appStaticPathsEncoded.set(originalAppPath, [page])
2130+
isStatic = true
2131+
} else if (
2132+
!hasGenerateStaticParams &&
2133+
(appConfig.dynamic === 'error' ||
2134+
appConfig.dynamic === 'force-static')
2135+
) {
2136+
appStaticPaths.set(originalAppPath, [])
2137+
appStaticPathsEncoded.set(originalAppPath, [])
2138+
isStatic = true
2139+
isRoutePPREnabled = false
21482140
}
21492141
}
21502142

packages/next/src/client/components/app-router.tsx

Lines changed: 1 addition & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,7 @@ import { unresolvedThenable } from './unresolved-thenable'
6161
import { NEXT_RSC_UNION_QUERY } from './app-router-headers'
6262
import { removeBasePath } from '../remove-base-path'
6363
import { hasBasePath } from '../has-base-path'
64-
import { PAGE_SEGMENT_KEY } from '../../shared/lib/segment'
65-
import type { Params } from '../../shared/lib/router/utils/route-matcher'
64+
import { getSelectedParams } from './router-reducer/compute-changed-path'
6665
import type { FlightRouterState } from '../../server/app-render/types'
6766
const isServer = typeof window === 'undefined'
6867

@@ -98,36 +97,6 @@ export function urlToUrlWithoutFlightMarker(url: string): URL {
9897
return urlWithoutFlightParameters
9998
}
10099

101-
// this function performs a depth-first search of the tree to find the selected
102-
// params
103-
function getSelectedParams(
104-
currentTree: FlightRouterState,
105-
params: Params = {}
106-
): Params {
107-
const parallelRoutes = currentTree[1]
108-
109-
for (const parallelRoute of Object.values(parallelRoutes)) {
110-
const segment = parallelRoute[0]
111-
const isDynamicParameter = Array.isArray(segment)
112-
const segmentValue = isDynamicParameter ? segment[1] : segment
113-
if (!segmentValue || segmentValue.startsWith(PAGE_SEGMENT_KEY)) continue
114-
115-
// Ensure catchAll and optional catchall are turned into an array
116-
const isCatchAll =
117-
isDynamicParameter && (segment[2] === 'c' || segment[2] === 'oc')
118-
119-
if (isCatchAll) {
120-
params[segment[0]] = segment[1].split('/')
121-
} else if (isDynamicParameter) {
122-
params[segment[0]] = segment[1]
123-
}
124-
125-
params = getSelectedParams(parallelRoute, params)
126-
}
127-
128-
return params
129-
}
130-
131100
type AppRouterProps = Omit<
132101
Omit<InitialRouterStateParameters, 'isServer' | 'location'>,
133102
'initialParallelRoutes'

packages/next/src/client/components/router-reducer/compute-changed-path.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type {
33
Segment,
44
} from '../../../server/app-render/types'
55
import { INTERCEPTION_ROUTE_MARKERS } from '../../../server/lib/interception-routes'
6+
import type { Params } from '../../../shared/lib/router/utils/route-matcher'
67
import {
78
isGroupSegment,
89
DEFAULT_SEGMENT_KEY,
@@ -130,3 +131,34 @@ export function computeChangedPath(
130131
// lightweight normalization to remove route groups
131132
return normalizeSegments(changedPath.split('/'))
132133
}
134+
135+
/**
136+
* Recursively extracts dynamic parameters from FlightRouterState.
137+
*/
138+
export function getSelectedParams(
139+
currentTree: FlightRouterState,
140+
params: Params = {}
141+
): Params {
142+
const parallelRoutes = currentTree[1]
143+
144+
for (const parallelRoute of Object.values(parallelRoutes)) {
145+
const segment = parallelRoute[0]
146+
const isDynamicParameter = Array.isArray(segment)
147+
const segmentValue = isDynamicParameter ? segment[1] : segment
148+
if (!segmentValue || segmentValue.startsWith(PAGE_SEGMENT_KEY)) continue
149+
150+
// Ensure catchAll and optional catchall are turned into an array
151+
const isCatchAll =
152+
isDynamicParameter && (segment[2] === 'c' || segment[2] === 'oc')
153+
154+
if (isCatchAll) {
155+
params[segment[0]] = segment[1].split('/')
156+
} else if (isDynamicParameter) {
157+
params[segment[0]] = segment[1]
158+
}
159+
160+
params = getSelectedParams(parallelRoute, params)
161+
}
162+
163+
return params
164+
}

packages/next/src/lib/generate-interception-routes-rewrites.ts

Lines changed: 1 addition & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { pathToRegexp } from 'next/dist/compiled/path-to-regexp'
22
import { NEXT_URL } from '../client/components/app-router-headers'
33
import {
4-
INTERCEPTION_ROUTE_MARKERS,
54
extractInterceptionRouteInformation,
65
isInterceptionRouteAppPath,
76
} from '../server/lib/interception-routes'
@@ -21,31 +20,6 @@ function toPathToRegexpPath(path: string): string {
2120
})
2221
}
2322

24-
// for interception routes we don't have access to the dynamic segments from the
25-
// referrer route so we mark them as noop for the app renderer so that it
26-
// can retrieve them from the router state later on. This also allows us to
27-
// compile the route properly with path-to-regexp, otherwise it will throw
28-
function voidParamsBeforeInterceptionMarker(path: string): string {
29-
let newPath = []
30-
31-
let foundInterceptionMarker = false
32-
for (const segment of path.split('/')) {
33-
if (
34-
INTERCEPTION_ROUTE_MARKERS.find((marker) => segment.startsWith(marker))
35-
) {
36-
foundInterceptionMarker = true
37-
}
38-
39-
if (segment.startsWith(':') && !foundInterceptionMarker) {
40-
newPath.push('__NEXT_EMPTY_PARAM__')
41-
} else {
42-
newPath.push(segment)
43-
}
44-
}
45-
46-
return newPath.join('/')
47-
}
48-
4923
export function generateInterceptionRoutesRewrites(
5024
appPaths: string[],
5125
basePath = ''
@@ -62,9 +36,7 @@ export function generateInterceptionRoutesRewrites(
6236
}/(.*)?`
6337

6438
const normalizedInterceptedRoute = toPathToRegexpPath(interceptedRoute)
65-
const normalizedAppPath = voidParamsBeforeInterceptionMarker(
66-
toPathToRegexpPath(appPath)
67-
)
39+
const normalizedAppPath = toPathToRegexpPath(appPath)
6840

6941
// pathToRegexp returns a regex that matches the path, but we need to
7042
// convert it to a string that can be used in a header value

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

Lines changed: 4 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ import {
3434
continueDynamicHTMLResume,
3535
continueDynamicDataResume,
3636
} from '../stream-utils/node-web-streams-helper'
37-
import { canSegmentBeOverridden } from '../../client/components/match-segments'
3837
import { stripInternalQueries } from '../internal-utils'
3938
import {
4039
NEXT_ROUTER_PREFETCH_HEADER,
@@ -103,7 +102,6 @@ import {
103102
StaticGenBailoutError,
104103
isStaticGenBailoutError,
105104
} from '../../client/components/static-generation-bailout'
106-
import { isInterceptionRouteAppPath } from '../lib/interception-routes'
107105
import { getStackWithoutErrorMessage } from '../../lib/format-server-error'
108106
import {
109107
usedDynamicAPIs,
@@ -162,63 +160,14 @@ function createNotFoundLoaderTree(loaderTree: LoaderTree): LoaderTree {
162160
return ['', {}, loaderTree[2]]
163161
}
164162

165-
/* This method is important for intercepted routes to function:
166-
* when a route is intercepted, e.g. /blog/[slug], it will be rendered
167-
* with the layout of the previous page, e.g. /profile/[id]. The problem is
168-
* that the loader tree needs to know the dynamic param in order to render (id and slug in the example).
169-
* Normally they are read from the path but since we are intercepting the route, the path would not contain id,
170-
* so we need to read it from the router state.
171-
*/
172-
function findDynamicParamFromRouterState(
173-
flightRouterState: FlightRouterState | undefined,
174-
segment: string
175-
): {
176-
param: string
177-
value: string | string[] | null
178-
treeSegment: Segment
179-
type: DynamicParamTypesShort
180-
} | null {
181-
if (!flightRouterState) {
182-
return null
183-
}
184-
185-
const treeSegment = flightRouterState[0]
186-
187-
if (canSegmentBeOverridden(segment, treeSegment)) {
188-
if (!Array.isArray(treeSegment) || Array.isArray(segment)) {
189-
return null
190-
}
191-
192-
return {
193-
param: treeSegment[0],
194-
value: treeSegment[1],
195-
treeSegment: treeSegment,
196-
type: treeSegment[2],
197-
}
198-
}
199-
200-
for (const parallelRouterState of Object.values(flightRouterState[1])) {
201-
const maybeDynamicParam = findDynamicParamFromRouterState(
202-
parallelRouterState,
203-
segment
204-
)
205-
if (maybeDynamicParam) {
206-
return maybeDynamicParam
207-
}
208-
}
209-
210-
return null
211-
}
212-
213163
export type CreateSegmentPath = (child: FlightSegmentPath) => FlightSegmentPath
214164

215165
/**
216166
* Returns a function that parses the dynamic segment and return the associated value.
217167
*/
218168
function makeGetDynamicParamFromSegment(
219169
params: { [key: string]: any },
220-
pagePath: string,
221-
flightRouterState: FlightRouterState | undefined
170+
pagePath: string
222171
): GetDynamicParamFromSegment {
223172
return function getDynamicParamFromSegment(
224173
// [slug] / [[slug]] / [...slug]
@@ -233,11 +182,6 @@ function makeGetDynamicParamFromSegment(
233182

234183
let value = params[key]
235184

236-
// this is a special marker that will be present for interception routes
237-
if (value === '__NEXT_EMPTY_PARAM__') {
238-
value = undefined
239-
}
240-
241185
if (Array.isArray(value)) {
242186
value = value.map((i) => encodeURIComponent(i))
243187
} else if (typeof value === 'string') {
@@ -283,8 +227,6 @@ function makeGetDynamicParamFromSegment(
283227
treeSegment: [key, value.join('/'), dynamicParamType],
284228
}
285229
}
286-
287-
return findDynamicParamFromRouterState(flightRouterState, segment)
288230
}
289231

290232
const type = getShortDynamicParamType(segmentParam.type)
@@ -820,16 +762,10 @@ async function renderToHTMLOrFlightImpl(
820762
* Router state provided from the client-side router. Used to handle rendering
821763
* from the common layout down. This value will be undefined if the request
822764
* is not a client-side navigation request or if the request is a prefetch
823-
* request (except when it's a prefetch request for an interception route
824-
* which is always dynamic).
765+
* request.
825766
*/
826767
const shouldProvideFlightRouterState =
827-
isRSCRequest &&
828-
(!isPrefetchRSCRequest ||
829-
!isRoutePPREnabled ||
830-
// Interception routes currently depend on the flight router state to
831-
// extract dynamic params.
832-
isInterceptionRouteAppPath(pagePath))
768+
isRSCRequest && (!isPrefetchRSCRequest || !isRoutePPREnabled)
833769

834770
const parsedFlightRouterState = parseAndValidateFlightRouterState(
835771
req.headers[NEXT_ROUTER_STATE_TREE.toLowerCase()]
@@ -854,10 +790,7 @@ async function renderToHTMLOrFlightImpl(
854790

855791
const getDynamicParamFromSegment = makeGetDynamicParamFromSegment(
856792
params,
857-
pagePath,
858-
// `FlightRouterState` is unconditionally provided here because this method uses it
859-
// to extract dynamic params as a fallback if they're not present in the path.
860-
parsedFlightRouterState
793+
pagePath
861794
)
862795

863796
// Get the nonce from the incoming request if it has one.

packages/next/src/server/app-render/parse-and-validate-flight-router-state.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@ import type { FlightRouterState } from './types'
22
import { flightRouterStateSchema } from './types'
33
import { assert } from 'next/dist/compiled/superstruct'
44

5+
export function parseAndValidateFlightRouterState(
6+
stateHeader: string | string[]
7+
): FlightRouterState
8+
export function parseAndValidateFlightRouterState(
9+
stateHeader: undefined
10+
): undefined
11+
export function parseAndValidateFlightRouterState(
12+
stateHeader: string | string[] | undefined
13+
): FlightRouterState | undefined
514
export function parseAndValidateFlightRouterState(
615
stateHeader: string | string[] | undefined
716
): FlightRouterState | undefined {

0 commit comments

Comments
 (0)