Skip to content

Commit dd57054

Browse files
authored
Fix parallel routes with server actions / revalidating router cache (#59585)
### What? There are a bunch of different bugs caused by the same underlying issue, but the common thread is that performing any sort of router cache update (either through `router.refresh()`, `revalidatePath()`, or `redirect()`) inside of a parallel route would break the router preventing subsequent actions, and not resolve any pending state such as from `useFormState`. ### Why? `applyPatch` is responsible for taking an update response from the server and merging it into the client router cache. However, there's specific bailout logic to skip over applying the patch to a `__DEFAULT__` segment (which corresponds with a `default.tsx` page). When the router detects a cache node that is expected to be rendered on the page but contains no data, the router will trigger a lazy fetch to retrieve the data that's expected to be there ([ref](https://github.com/vercel/next.js/blob/5adacb69126e0fd7dff7ebd45278c0dfd42f6116/packages/next/src/client/components/layout-router.tsx#L359-L370)) and then update the router cache once the data resolves ([ref](https://github.com/vercel/next.js/blob/5adacb69126e0fd7dff7ebd45278c0dfd42f6116/packages/next/src/client/components/layout-router.tsx#L399-L404)). This is causing the router to get stuck in a loop: it'll fetch the data for the cache node, send the data to the router reducer to merge it into the existing cache nodes, skip merging that data in for `__DEFAULT__` segments, and repeat. ### How? We currently assign `__DEFAULT__` to have `notFound()` behavior when there isn't a `default.tsx` component for a particular segment. This makes it so that when loading a page that renders a slot without slot content / a `default`, it 404s. But when performing a client-side navigation, the intended behavior is different: we keep whatever was in the `default` slots place, until the user refreshes the page, which would then 404. However, this logic is incorrect when triggering any of the above mentioned cache node revalidation strategies: if we always skip applying to the `__DEFAULT__` segment, slots will never properly handle reducer actions that rely on making changes to their cache nodes. This splits these different `applyPatch` functions: one that will apply to the full tree, and another that'll apply to everything except the default segments with the existing bailout condition. Fixes #54173 Fixes #58772 Fixes #54723 Fixes #57665 Closes NEXT-1706 Closes NEXT-1815 Closes NEXT-1812
1 parent 65634be commit dd57054

File tree

22 files changed

+347
-32
lines changed

22 files changed

+347
-32
lines changed

packages/next/src/client/components/router-reducer/apply-router-state-patch-to-tree.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type {
33
FlightData,
44
FlightRouterState,
55
} from '../../../server/app-render/types'
6-
import { applyRouterStatePatchToTree } from './apply-router-state-patch-to-tree'
6+
import { applyRouterStatePatchToTreeSkipDefault } from './apply-router-state-patch-to-tree'
77

88
const getInitialRouterStateTree = (): FlightRouterState => [
99
'',
@@ -55,7 +55,7 @@ describe('applyRouterStatePatchToTree', () => {
5555
const [treePatch /*, cacheNodeSeedData, head*/] = flightDataPath.slice(-3)
5656
const flightSegmentPath = flightDataPath.slice(0, -4)
5757

58-
const newRouterStateTree = applyRouterStatePatchToTree(
58+
const newRouterStateTree = applyRouterStatePatchToTreeSkipDefault(
5959
['', ...flightSegmentPath],
6060
initialRouterStateTree,
6161
treePatch

packages/next/src/client/components/router-reducer/apply-router-state-patch-to-tree.ts

Lines changed: 60 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,18 @@ import { matchSegment } from '../match-segments'
1010
*/
1111
function applyPatch(
1212
initialTree: FlightRouterState,
13-
patchTree: FlightRouterState
13+
patchTree: FlightRouterState,
14+
applyPatchToDefaultSegment: boolean = false
1415
): FlightRouterState {
1516
const [initialSegment, initialParallelRoutes] = initialTree
1617
const [patchSegment, patchParallelRoutes] = patchTree
1718

18-
// if the applied patch segment is __DEFAULT__ then we can ignore it and return the initial tree
19+
// if the applied patch segment is __DEFAULT__ then it can be ignored in favor of the initial tree
1920
// this is because the __DEFAULT__ segment is used as a placeholder on navigation
21+
// however, there are cases where we _do_ want to apply the patch to the default segment,
22+
// such as when revalidating the router cache with router.refresh/revalidatePath
2023
if (
24+
!applyPatchToDefaultSegment &&
2125
patchSegment === DEFAULT_SEGMENT_KEY &&
2226
initialSegment !== DEFAULT_SEGMENT_KEY
2327
) {
@@ -32,7 +36,8 @@ function applyPatch(
3236
if (isInPatchTreeParallelRoutes) {
3337
newParallelRoutes[key] = applyPatch(
3438
initialParallelRoutes[key],
35-
patchParallelRoutes[key]
39+
patchParallelRoutes[key],
40+
applyPatchToDefaultSegment
3641
)
3742
} else {
3843
newParallelRoutes[key] = initialParallelRoutes[key]
@@ -67,19 +72,21 @@ function applyPatch(
6772
return patchTree
6873
}
6974

70-
/**
71-
* Apply the router state from the Flight response. Creates a new router state tree.
72-
*/
73-
export function applyRouterStatePatchToTree(
75+
function applyRouterStatePatchToTreeImpl(
7476
flightSegmentPath: FlightSegmentPath,
7577
flightRouterState: FlightRouterState,
76-
treePatch: FlightRouterState
78+
treePatch: FlightRouterState,
79+
applyPatchDefaultSegment: boolean = false
7780
): FlightRouterState | null {
7881
const [segment, parallelRoutes, , , isRootLayout] = flightRouterState
7982

8083
// Root refresh
8184
if (flightSegmentPath.length === 1) {
82-
const tree: FlightRouterState = applyPatch(flightRouterState, treePatch)
85+
const tree: FlightRouterState = applyPatch(
86+
flightRouterState,
87+
treePatch,
88+
applyPatchDefaultSegment
89+
)
8390

8491
return tree
8592
}
@@ -95,12 +102,17 @@ export function applyRouterStatePatchToTree(
95102

96103
let parallelRoutePatch
97104
if (lastSegment) {
98-
parallelRoutePatch = applyPatch(parallelRoutes[parallelRouteKey], treePatch)
105+
parallelRoutePatch = applyPatch(
106+
parallelRoutes[parallelRouteKey],
107+
treePatch,
108+
applyPatchDefaultSegment
109+
)
99110
} else {
100-
parallelRoutePatch = applyRouterStatePatchToTree(
111+
parallelRoutePatch = applyRouterStatePatchToTreeImpl(
101112
flightSegmentPath.slice(2),
102113
parallelRoutes[parallelRouteKey],
103-
treePatch
114+
treePatch,
115+
applyPatchDefaultSegment
104116
)
105117

106118
if (parallelRoutePatch === null) {
@@ -123,3 +135,39 @@ export function applyRouterStatePatchToTree(
123135

124136
return tree
125137
}
138+
139+
/**
140+
* Apply the router state from the Flight response to the tree, including default segments.
141+
* Useful for patching the router cache when we expect to revalidate the full tree, such as with router.refresh or revalidatePath.
142+
* Creates a new router state tree.
143+
*/
144+
export function applyRouterStatePatchToFullTree(
145+
flightSegmentPath: FlightSegmentPath,
146+
flightRouterState: FlightRouterState,
147+
treePatch: FlightRouterState
148+
): FlightRouterState | null {
149+
return applyRouterStatePatchToTreeImpl(
150+
flightSegmentPath,
151+
flightRouterState,
152+
treePatch,
153+
true
154+
)
155+
}
156+
157+
/**
158+
* Apply the router state from the Flight response, but skip patching default segments.
159+
* Useful for patching the router cache when navigating, where we persist the existing default segment if there isn't a new one.
160+
* Creates a new router state tree.
161+
*/
162+
export function applyRouterStatePatchToTreeSkipDefault(
163+
flightSegmentPath: FlightSegmentPath,
164+
flightRouterState: FlightRouterState,
165+
treePatch: FlightRouterState
166+
): FlightRouterState | null {
167+
return applyRouterStatePatchToTreeImpl(
168+
flightSegmentPath,
169+
flightRouterState,
170+
treePatch,
171+
false
172+
)
173+
}

packages/next/src/client/components/router-reducer/create-router-cache-key.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,17 @@ export function createRouterCacheKey(
55
segment: Segment,
66
withoutSearchParameters: boolean = false
77
) {
8-
return Array.isArray(segment)
9-
? `${segment[0]}|${segment[1]}|${segment[2]}`.toLowerCase()
10-
: withoutSearchParameters && segment.startsWith(PAGE_SEGMENT_KEY)
11-
? PAGE_SEGMENT_KEY
12-
: segment
8+
// if the segment is an array, it means it's a dynamic segment
9+
// for example, ['lang', 'en', 'd']. We need to convert it to a string to store it as a cache node key.
10+
if (Array.isArray(segment)) {
11+
return `${segment[0]}|${segment[1]}|${segment[2]}`.toLowerCase()
12+
}
13+
14+
// Page segments might have search parameters, ie __PAGE__?foo=bar
15+
// When `withoutSearchParameters` is true, we only want to return the page segment
16+
if (withoutSearchParameters && segment.startsWith(PAGE_SEGMENT_KEY)) {
17+
return PAGE_SEGMENT_KEY
18+
}
19+
20+
return segment
1321
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { fetchServerResponse } from '../fetch-server-response'
22
import { createHrefFromUrl } from '../create-href-from-url'
3-
import { applyRouterStatePatchToTree } from '../apply-router-state-patch-to-tree'
3+
import { applyRouterStatePatchToTreeSkipDefault } from '../apply-router-state-patch-to-tree'
44
import { isNavigatingToNewRootLayout } from '../is-navigating-to-new-root-layout'
55
import type {
66
ReadonlyReducerState,
@@ -63,7 +63,7 @@ function fastRefreshReducerImpl(
6363

6464
// Given the path can only have two items the items are only the router state and rsc for the root.
6565
const [treePatch] = flightDataPath
66-
const newTree = applyRouterStatePatchToTree(
66+
const newTree = applyRouterStatePatchToTreeSkipDefault(
6767
// TODO-APP: remove ''
6868
[''],
6969
currentTree,

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type { FetchServerResponseResult } from '../fetch-server-response'
88
import { createHrefFromUrl } from '../create-href-from-url'
99
import { invalidateCacheBelowFlightSegmentPath } from '../invalidate-cache-below-flight-segmentpath'
1010
import { fillCacheWithDataProperty } from '../fill-cache-with-data-property'
11-
import { applyRouterStatePatchToTree } from '../apply-router-state-patch-to-tree'
11+
import { applyRouterStatePatchToTreeSkipDefault } from '../apply-router-state-patch-to-tree'
1212
import { shouldHardNavigate } from '../should-hard-navigate'
1313
import { isNavigatingToNewRootLayout } from '../is-navigating-to-new-root-layout'
1414
import type {
@@ -187,7 +187,7 @@ function navigateReducer_noPPR(
187187
const flightSegmentPathWithLeadingEmpty = ['', ...flightSegmentPath]
188188

189189
// Create new tree based on the flightSegmentPath and router state patch
190-
let newTree = applyRouterStatePatchToTree(
190+
let newTree = applyRouterStatePatchToTreeSkipDefault(
191191
// TODO-APP: remove ''
192192
flightSegmentPathWithLeadingEmpty,
193193
currentTree,
@@ -197,7 +197,7 @@ function navigateReducer_noPPR(
197197
// If the tree patch can't be applied to the current tree then we use the tree at time of prefetch
198198
// TODO-APP: This should instead fill in the missing pieces in `currentTree` with the data from `treeAtTimeOfPrefetch`, then apply the patch.
199199
if (newTree === null) {
200-
newTree = applyRouterStatePatchToTree(
200+
newTree = applyRouterStatePatchToTreeSkipDefault(
201201
// TODO-APP: remove ''
202202
flightSegmentPathWithLeadingEmpty,
203203
treeAtTimeOfPrefetch,
@@ -381,7 +381,7 @@ function navigateReducer_PPR(
381381
const flightSegmentPathWithLeadingEmpty = ['', ...flightSegmentPath]
382382

383383
// Create new tree based on the flightSegmentPath and router state patch
384-
let newTree = applyRouterStatePatchToTree(
384+
let newTree = applyRouterStatePatchToTreeSkipDefault(
385385
// TODO-APP: remove ''
386386
flightSegmentPathWithLeadingEmpty,
387387
currentTree,
@@ -391,7 +391,7 @@ function navigateReducer_PPR(
391391
// If the tree patch can't be applied to the current tree then we use the tree at time of prefetch
392392
// TODO-APP: This should instead fill in the missing pieces in `currentTree` with the data from `treeAtTimeOfPrefetch`, then apply the patch.
393393
if (newTree === null) {
394-
newTree = applyRouterStatePatchToTree(
394+
newTree = applyRouterStatePatchToTreeSkipDefault(
395395
// TODO-APP: remove ''
396396
flightSegmentPathWithLeadingEmpty,
397397
treeAtTimeOfPrefetch,

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { fetchServerResponse } from '../fetch-server-response'
22
import { createHrefFromUrl } from '../create-href-from-url'
3-
import { applyRouterStatePatchToTree } from '../apply-router-state-patch-to-tree'
3+
import { applyRouterStatePatchToFullTree } from '../apply-router-state-patch-to-tree'
44
import { isNavigatingToNewRootLayout } from '../is-navigating-to-new-root-layout'
55
import type {
66
Mutable,
@@ -61,7 +61,7 @@ export function refreshReducer(
6161

6262
// Given the path can only have two items the items are only the router state and rsc for the root.
6363
const [treePatch] = flightDataPath
64-
const newTree = applyRouterStatePatchToTree(
64+
const newTree = applyRouterStatePatchToFullTree(
6565
// TODO-APP: remove ''
6666
[''],
6767
currentTree,

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import type {
3131
import { addBasePath } from '../../../add-base-path'
3232
import { createHrefFromUrl } from '../create-href-from-url'
3333
import { handleExternalUrl } from './navigate-reducer'
34-
import { applyRouterStatePatchToTree } from '../apply-router-state-patch-to-tree'
34+
import { applyRouterStatePatchToFullTree } from '../apply-router-state-patch-to-tree'
3535
import { isNavigatingToNewRootLayout } from '../is-navigating-to-new-root-layout'
3636
import type { CacheNode } from '../../../../shared/lib/app-router-context.shared-runtime'
3737
import { handleMutable } from '../handle-mutable'
@@ -216,7 +216,7 @@ export function serverActionReducer(
216216

217217
// Given the path can only have two items the items are only the router state and rsc for the root.
218218
const [treePatch] = flightDataPath
219-
const newTree = applyRouterStatePatchToTree(
219+
const newTree = applyRouterStatePatchToFullTree(
220220
// TODO-APP: remove ''
221221
[''],
222222
currentTree,

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { createHrefFromUrl } from '../create-href-from-url'
2-
import { applyRouterStatePatchToTree } from '../apply-router-state-patch-to-tree'
2+
import { applyRouterStatePatchToTreeSkipDefault } from '../apply-router-state-patch-to-tree'
33
import { isNavigatingToNewRootLayout } from '../is-navigating-to-new-root-layout'
44
import type {
55
ServerPatchAction,
@@ -41,7 +41,7 @@ export function serverPatchReducer(
4141
const flightSegmentPath = flightDataPath.slice(0, -4)
4242

4343
const [treePatch] = flightDataPath.slice(-3, -2)
44-
const newTree = applyRouterStatePatchToTree(
44+
const newTree = applyRouterStatePatchToTreeSkipDefault(
4545
// TODO-APP: remove ''
4646
['', ...flightSegmentPath],
4747
currentTree,
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function Default() {
2+
return null
3+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function Page() {
2+
return null
3+
}

0 commit comments

Comments
 (0)