Skip to content

Commit 30c64ba

Browse files
authored
Fix interception route rewrites for nested dynamic routes (#84413)
### What? Fixes interception routes with dynamic parameters that were generating rewrite rules in an incorrect format, preventing proper URL rewriting by Vercel and Next.js. ### Why? The failing test `parallel-routes-and-interception-nested-dynamic-routes` revealed that interception routes with dynamic segments weren't working correctly when deployed. The root cause was that the previous implementation using `safePathToRegexp` produced rewrite destinations that weren't in a format compatible with direct parameter substitution during the rewrite process. When the router tried to rewrite URLs to their intercepted destinations, the parameter format prevented Vercel/Next.js from properly substituting the captured values, causing routes to fail. ### How? Completely rewrote the `generate-interception-routes-rewrites` logic to use `getNamedRouteRegex` instead of `safePathToRegexp`. This ensures: 1. **Correct rewrite format**: Rewrites are now generated in a format that allows direct parameter substitution 2. **Parameter consistency**: Source regex is generated first, then its parameter reference is passed to destination generation to ensure parameter names match for substitution 3. **Clean URLs**: Added `stripNormalizedSeparators` to prevent `_NEXTSEP_` internal separator from leaking into client-facing URLs 4. **Stricter matching**: Updated router reducers to pass `next-url` header for the stricter Next-Url regex matching required by the new rewrite mechanism **Testing:** - Added 23 comprehensive tests for rewrite generation covering all interception marker types - Added 18 tests for route matching utilities - All existing tests pass including the previously failing `parallel-routes-and-interception-nested-dynamic-routes` Fixes the failing test: `test/e2e/app-dir/parallel-routes-and-interception-nested-dynamic-routes/parallel-routes-and-interception-nested-dynamic-routes.test.ts` NAR-432
1 parent ec56020 commit 30c64ba

21 files changed

+3050
-214
lines changed

packages/next/errors.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -865,5 +865,7 @@
865865
"864": "Missing value for segment key: \"%s\" with dynamic param type: %s",
866866
"865": "`experimental.rdcForNavigations` is enabled, but `experimental.cacheComponents` is not.",
867867
"866": "Both \"%s\" and \"%s\" files are detected. Please use \"%s\" instead.",
868-
"867": "The %s \"%s\" must export a %s or a \\`default\\` function"
868+
"867": "The %s \"%s\" must export a %s or a \\`default\\` function",
869+
"868": "No reference found for param: %s in reference: %s",
870+
"869": "No reference found for segment: %s with reference: %s"
869871
}

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -396,7 +396,7 @@ function Router({
396396
}
397397
}, [])
398398

399-
const { cache, tree, nextUrl, focusAndScrollRef } = state
399+
const { cache, tree, nextUrl, focusAndScrollRef, previousNextUrl } = state
400400

401401
const matchingHead = useMemo(() => {
402402
return findHeadInCache(cache, tree[1])
@@ -423,8 +423,9 @@ function Router({
423423
tree,
424424
focusAndScrollRef,
425425
nextUrl,
426+
previousNextUrl,
426427
}
427-
}, [tree, focusAndScrollRef, nextUrl])
428+
}, [tree, focusAndScrollRef, nextUrl, previousNextUrl])
428429

429430
let head
430431
if (matchingHead !== null) {

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -389,7 +389,14 @@ function InnerLayoutRouter({
389389
new URL(url, location.origin),
390390
{
391391
flightRouterState: refetchTree,
392-
nextUrl: includeNextUrl ? context.nextUrl : null,
392+
nextUrl: includeNextUrl
393+
? // We always send the last next-url, not the current when
394+
// performing a dynamic request. This is because we update
395+
// the next-url after a navigation, but we want the same
396+
// interception route to be matched that used the last
397+
// next-url.
398+
context.previousNextUrl || context.nextUrl
399+
: null,
393400
}
394401
).then((serverResponse) => {
395402
startTransition(() => {

packages/next/src/client/components/router-reducer/create-initial-router-state.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ describe('createInitialRouterState', () => {
138138
},
139139
cache: expectedCache,
140140
nextUrl: '/linking',
141+
previousNextUrl: null,
141142
}
142143

143144
expect(state).toMatchObject(expected)

packages/next/src/client/components/router-reducer/create-initial-router-state.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ export function createInitialRouterState({
110110
// the || operator is intentional, the pathname can be an empty string
111111
(extractPathFromFlightRouterState(initialTree) || location?.pathname) ??
112112
null,
113+
previousNextUrl: null,
113114
}
114115

115116
if (process.env.NODE_ENV !== 'development' && location) {

packages/next/src/client/components/router-reducer/handle-mutable.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,15 @@ export function handleMutable(
1616
// shouldScroll is true by default, can override to false.
1717
const shouldScroll = mutable.shouldScroll ?? true
1818

19+
let previousNextUrl = state.previousNextUrl
1920
let nextUrl = state.nextUrl
2021

2122
if (isNotUndefined(mutable.patchedTree)) {
2223
// If we received a patched tree, we need to compute the changed path.
2324
const changedPath = computeChangedPath(state.tree, mutable.patchedTree)
2425
if (changedPath) {
2526
// If the tree changed, we need to update the nextUrl
27+
previousNextUrl = nextUrl
2628
nextUrl = changedPath
2729
} else if (!nextUrl) {
2830
// if the tree ends up being the same (ie, no changed path), and we don't have a nextUrl, then we should use the canonicalUrl
@@ -84,5 +86,6 @@ export function handleMutable(
8486
? mutable.patchedTree
8587
: state.tree,
8688
nextUrl,
89+
previousNextUrl: previousNextUrl,
8790
}
8891
}

packages/next/src/client/components/router-reducer/reducers/navigate-reducer.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -391,7 +391,12 @@ export function navigateReducer(
391391
new URL(updatedCanonicalUrl, url.origin),
392392
{
393393
flightRouterState: dynamicRequestTree,
394-
nextUrl: state.nextUrl,
394+
// We always send the last next-url, not the current when
395+
// performing a dynamic request. This is because we update
396+
// the next-url after a navigation, but we want the same
397+
// interception route to be matched that used the last
398+
// next-url.
399+
nextUrl: state.previousNextUrl || state.nextUrl,
395400
}
396401
)
397402

packages/next/src/client/components/router-reducer/reducers/restore-reducer.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,5 +45,6 @@ export function restoreReducer(
4545
// Restore provided tree
4646
tree: treeToRestore,
4747
nextUrl: extractPathFromFlightRouterState(treeToRestore) ?? url.pathname,
48+
previousNextUrl: null,
4849
}
4950
}

packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -257,8 +257,14 @@ export function serverActionReducer(
257257
// Otherwise the server action might be intercepted with the wrong action id
258258
// (ie, one that corresponds with the intercepted route)
259259
const nextUrl =
260-
state.nextUrl && hasInterceptionRouteInCurrentTree(state.tree)
261-
? state.nextUrl
260+
// We always send the last next-url, not the current when
261+
// performing a dynamic request. This is because we update
262+
// the next-url after a navigation, but we want the same
263+
// interception route to be matched that used the last
264+
// next-url.
265+
(state.previousNextUrl || state.nextUrl) &&
266+
hasInterceptionRouteInCurrentTree(state.tree)
267+
? state.previousNextUrl || state.nextUrl
262268
: null
263269

264270
const navigatedAt = Date.now()

packages/next/src/client/components/router-reducer/router-reducer-types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,11 @@ export type AppRouterState = {
254254
* The underlying "url" representing the UI state, which is used for intercepting routes.
255255
*/
256256
nextUrl: string | null
257+
258+
/**
259+
* The previous next-url that was used previous to a dynamic navigation.
260+
*/
261+
previousNextUrl: string | null
257262
}
258263

259264
export type ReadonlyReducerState = Readonly<AppRouterState>

0 commit comments

Comments
 (0)