fix revalidation/refresh behavior with parallel routes (#63607)

`applyRouterStatePatchToTree` had been refactored to support the case of
not skipping the `__DEFAULT__` segment, so that `router.refresh` or
revalidating in a server action wouldn't break the router. (More details
in this #59585)

This was a stop-gap and not an ideal solution, as this behavior means
`router.refresh()` would effectively behave like reloading the page,
where "stale" segments (ones that went from `__PAGE__` -> `__DEFAULT__`)
would disappear.

This PR reverts that handling. The next PR in this stack (#63608) adds
handling to refresh "stale" segments as well.

Note: We expect the test case that was added in #59585 to fail here, but
it is re-enabled in the next PR in the stack.

Note 2: #63608 was accidentally merged into this PR, despite being a
separate entry in the stack. As such, I've copied the issues from that
PR into this one so they can be linked. See the notes from that PR for
the refresh fix details.

Fixes #60815
Fixes #60950
Fixes #51711
Fixes #51714
Fixes #58715
Fixes #60948
Fixes #62213
Fixes #61341

Closes [NEXT-1845](https://linear.app/vercel/issue/NEXT-1845)
Closes [NEXT-2030](https://linear.app/vercel/issue/NEXT-2030)

<!-- Thanks for opening a PR! Your contribution is much appreciated.
To make sure your PR is handled as smoothly as possible we request that
you follow the checklist sections below.
Choose the right checklist for the change(s) that you're making:

## For Contributors

### Improving Documentation

- Run `pnpm prettier-fix` to fix formatting issues before opening the
PR.
- Read the Docs Contribution Guide to ensure your contribution follows
the docs guidelines:
https://nextjs.org/docs/community/contribution-guide

### Adding or Updating Examples

- The "examples guidelines" are followed from our contributing doc
https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md
- Make sure the linting passes by running `pnpm build && pnpm lint`. See
https://github.com/vercel/next.js/blob/canary/contributing/repository/linting.md

### Fixing a bug

- Related issues linked using `fixes #number`
- Tests added. See:
https://github.com/vercel/next.js/blob/canary/contributing/core/testing.md#writing-tests-for-nextjs
- Errors have a helpful link attached, see
https://github.com/vercel/next.js/blob/canary/contributing.md

### Adding a feature

- Implements an existing feature request or RFC. Make sure the feature
request has been accepted for implementation before opening a PR. (A
discussion must be opened, see
https://github.com/vercel/next.js/discussions/new?category=ideas)
- Related issues/discussions are linked using `fixes #number`
- e2e tests added
(https://github.com/vercel/next.js/blob/canary/contributing/core/testing.md#writing-tests-for-nextjs)
- Documentation added
- Telemetry added. In case of a feature if it's used or not.
- Errors have a helpful link attached, see
https://github.com/vercel/next.js/blob/canary/contributing.md


## For Maintainers

- Minimal description (aim for explaining to someone not on the team to
understand the PR)
- When linking to a Slack thread, you might want to share details of the
conclusion
- Link both the Linear (Fixes NEXT-xxx) and the GitHub issues
- Add review comments if necessary to explain to the reviewer the logic
behind a change

### What?

### Why?

### How?

Closes NEXT-
Fixes #

-->


Closes NEXT-2903
This commit is contained in:
Zack Tanner 2024-03-28 05:59:27 -07:00 committed by GitHub
parent 5a1409179c
commit 68de4c0357
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 607 additions and 90 deletions

View file

@ -3,7 +3,7 @@ import type {
FlightData, FlightData,
FlightRouterState, FlightRouterState,
} from '../../../server/app-render/types' } from '../../../server/app-render/types'
import { applyRouterStatePatchToTreeSkipDefault } from './apply-router-state-patch-to-tree' import { applyRouterStatePatchToTree } from './apply-router-state-patch-to-tree'
const getInitialRouterStateTree = (): FlightRouterState => [ const getInitialRouterStateTree = (): FlightRouterState => [
'', '',
@ -55,10 +55,11 @@ describe('applyRouterStatePatchToTree', () => {
const [treePatch /*, cacheNodeSeedData, head*/] = flightDataPath.slice(-3) const [treePatch /*, cacheNodeSeedData, head*/] = flightDataPath.slice(-3)
const flightSegmentPath = flightDataPath.slice(0, -4) const flightSegmentPath = flightDataPath.slice(0, -4)
const newRouterStateTree = applyRouterStatePatchToTreeSkipDefault( const newRouterStateTree = applyRouterStatePatchToTree(
['', ...flightSegmentPath], ['', ...flightSegmentPath],
initialRouterStateTree, initialRouterStateTree,
treePatch treePatch,
''
) )
expect(newRouterStateTree).toMatchObject([ expect(newRouterStateTree).toMatchObject([

View file

@ -4,6 +4,7 @@ import type {
} from '../../../server/app-render/types' } from '../../../server/app-render/types'
import { DEFAULT_SEGMENT_KEY } from '../../../shared/lib/segment' import { DEFAULT_SEGMENT_KEY } from '../../../shared/lib/segment'
import { matchSegment } from '../match-segments' import { matchSegment } from '../match-segments'
import { addRefreshMarkerToActiveParallelSegments } from './refetch-inactive-parallel-segments'
/** /**
* Deep merge of the two router states. Parallel route keys are preserved if the patch doesn't have them. * Deep merge of the two router states. Parallel route keys are preserved if the patch doesn't have them.
@ -11,17 +12,14 @@ import { matchSegment } from '../match-segments'
function applyPatch( function applyPatch(
initialTree: FlightRouterState, initialTree: FlightRouterState,
patchTree: FlightRouterState, patchTree: FlightRouterState,
applyPatchToDefaultSegment: boolean = false flightSegmentPath: FlightSegmentPath
): FlightRouterState { ): FlightRouterState {
const [initialSegment, initialParallelRoutes] = initialTree const [initialSegment, initialParallelRoutes] = initialTree
const [patchSegment, patchParallelRoutes] = patchTree const [patchSegment, patchParallelRoutes] = patchTree
// if the applied patch segment is __DEFAULT__ then it can be ignored in favor of the initial tree // if the applied patch segment is __DEFAULT__ then it can be ignored in favor of the initial tree
// this is because the __DEFAULT__ segment is used as a placeholder on navigation // this is because the __DEFAULT__ segment is used as a placeholder on navigation
// however, there are cases where we _do_ want to apply the patch to the default segment,
// such as when revalidating the router cache with router.refresh/revalidatePath
if ( if (
!applyPatchToDefaultSegment &&
patchSegment === DEFAULT_SEGMENT_KEY && patchSegment === DEFAULT_SEGMENT_KEY &&
initialSegment !== DEFAULT_SEGMENT_KEY initialSegment !== DEFAULT_SEGMENT_KEY
) { ) {
@ -37,7 +35,7 @@ function applyPatch(
newParallelRoutes[key] = applyPatch( newParallelRoutes[key] = applyPatch(
initialParallelRoutes[key], initialParallelRoutes[key],
patchParallelRoutes[key], patchParallelRoutes[key],
applyPatchToDefaultSegment flightSegmentPath
) )
} else { } else {
newParallelRoutes[key] = initialParallelRoutes[key] newParallelRoutes[key] = initialParallelRoutes[key]
@ -54,6 +52,7 @@ function applyPatch(
const tree: FlightRouterState = [initialSegment, newParallelRoutes] const tree: FlightRouterState = [initialSegment, newParallelRoutes]
// Copy over the existing tree
if (initialTree[2]) { if (initialTree[2]) {
tree[2] = initialTree[2] tree[2] = initialTree[2]
} }
@ -72,20 +71,26 @@ function applyPatch(
return patchTree return patchTree
} }
function applyRouterStatePatchToTreeImpl( /**
* Apply the router state from the Flight response, but skip patching default segments.
* Useful for patching the router cache when navigating, where we persist the existing default segment if there isn't a new one.
* Creates a new router state tree.
*/
export function applyRouterStatePatchToTree(
flightSegmentPath: FlightSegmentPath, flightSegmentPath: FlightSegmentPath,
flightRouterState: FlightRouterState, flightRouterState: FlightRouterState,
treePatch: FlightRouterState, treePatch: FlightRouterState,
applyPatchDefaultSegment: boolean = false pathname: string
): FlightRouterState | null { ): FlightRouterState | null {
const [segment, parallelRoutes, , , isRootLayout] = flightRouterState const [segment, parallelRoutes, url, refetch, isRootLayout] =
flightRouterState
// Root refresh // Root refresh
if (flightSegmentPath.length === 1) { if (flightSegmentPath.length === 1) {
const tree: FlightRouterState = applyPatch( const tree: FlightRouterState = applyPatch(
flightRouterState, flightRouterState,
treePatch, treePatch,
applyPatchDefaultSegment flightSegmentPath
) )
return tree return tree
@ -105,14 +110,14 @@ function applyRouterStatePatchToTreeImpl(
parallelRoutePatch = applyPatch( parallelRoutePatch = applyPatch(
parallelRoutes[parallelRouteKey], parallelRoutes[parallelRouteKey],
treePatch, treePatch,
applyPatchDefaultSegment flightSegmentPath
) )
} else { } else {
parallelRoutePatch = applyRouterStatePatchToTreeImpl( parallelRoutePatch = applyRouterStatePatchToTree(
flightSegmentPath.slice(2), flightSegmentPath.slice(2),
parallelRoutes[parallelRouteKey], parallelRoutes[parallelRouteKey],
treePatch, treePatch,
applyPatchDefaultSegment pathname
) )
if (parallelRoutePatch === null) { if (parallelRoutePatch === null) {
@ -126,6 +131,8 @@ function applyRouterStatePatchToTreeImpl(
...parallelRoutes, ...parallelRoutes,
[parallelRouteKey]: parallelRoutePatch, [parallelRouteKey]: parallelRoutePatch,
}, },
url,
refetch,
] ]
// Current segment is the root layout // Current segment is the root layout
@ -133,41 +140,7 @@ function applyRouterStatePatchToTreeImpl(
tree[4] = true tree[4] = true
} }
addRefreshMarkerToActiveParallelSegments(tree, pathname)
return tree return tree
} }
/**
* Apply the router state from the Flight response to the tree, including default segments.
* Useful for patching the router cache when we expect to revalidate the full tree, such as with router.refresh or revalidatePath.
* Creates a new router state tree.
*/
export function applyRouterStatePatchToFullTree(
flightSegmentPath: FlightSegmentPath,
flightRouterState: FlightRouterState,
treePatch: FlightRouterState
): FlightRouterState | null {
return applyRouterStatePatchToTreeImpl(
flightSegmentPath,
flightRouterState,
treePatch,
true
)
}
/**
* Apply the router state from the Flight response, but skip patching default segments.
* Useful for patching the router cache when navigating, where we persist the existing default segment if there isn't a new one.
* Creates a new router state tree.
*/
export function applyRouterStatePatchToTreeSkipDefault(
flightSegmentPath: FlightSegmentPath,
flightRouterState: FlightRouterState,
treePatch: FlightRouterState
): FlightRouterState | null {
return applyRouterStatePatchToTreeImpl(
flightSegmentPath,
flightRouterState,
treePatch,
false
)
}

View file

@ -11,6 +11,7 @@ import { fillLazyItemsTillLeafWithHead } from './fill-lazy-items-till-leaf-with-
import { extractPathFromFlightRouterState } from './compute-changed-path' import { extractPathFromFlightRouterState } from './compute-changed-path'
import { createPrefetchCacheEntryForInitialLoad } from './prefetch-cache-utils' import { createPrefetchCacheEntryForInitialLoad } from './prefetch-cache-utils'
import { PrefetchKind, type PrefetchCacheEntry } from './router-reducer-types' import { PrefetchKind, type PrefetchCacheEntry } from './router-reducer-types'
import { addRefreshMarkerToActiveParallelSegments } from './refetch-inactive-parallel-segments'
export interface InitialRouterStateParameters { export interface InitialRouterStateParameters {
buildId: string buildId: string
@ -48,6 +49,8 @@ export function createInitialRouterState({
loading: initialSeedData[3], loading: initialSeedData[3],
} }
addRefreshMarkerToActiveParallelSegments(initialTree, initialCanonicalUrl)
const prefetchCache = new Map<string, PrefetchCacheEntry>() const prefetchCache = new Map<string, PrefetchCacheEntry>()
// When the cache hasn't been seeded yet we fill the cache with the head. // When the cache hasn't been seeded yet we fill the cache with the head.

View file

@ -1,6 +1,6 @@
import { fetchServerResponse } from '../fetch-server-response' import { fetchServerResponse } from '../fetch-server-response'
import { createHrefFromUrl } from '../create-href-from-url' import { createHrefFromUrl } from '../create-href-from-url'
import { applyRouterStatePatchToTreeSkipDefault } from '../apply-router-state-patch-to-tree' import { applyRouterStatePatchToTree } from '../apply-router-state-patch-to-tree'
import { isNavigatingToNewRootLayout } from '../is-navigating-to-new-root-layout' import { isNavigatingToNewRootLayout } from '../is-navigating-to-new-root-layout'
import type { import type {
ReadonlyReducerState, ReadonlyReducerState,
@ -69,11 +69,12 @@ function fastRefreshReducerImpl(
// Given the path can only have two items the items are only the router state and rsc for the root. // Given the path can only have two items the items are only the router state and rsc for the root.
const [treePatch] = flightDataPath const [treePatch] = flightDataPath
const newTree = applyRouterStatePatchToTreeSkipDefault( const newTree = applyRouterStatePatchToTree(
// TODO-APP: remove '' // TODO-APP: remove ''
[''], [''],
currentTree, currentTree,
treePatch treePatch,
location.pathname
) )
if (newTree === null) { if (newTree === null) {

View file

@ -6,7 +6,7 @@ import type {
import { fetchServerResponse } from '../fetch-server-response' import { fetchServerResponse } from '../fetch-server-response'
import { createHrefFromUrl } from '../create-href-from-url' import { createHrefFromUrl } from '../create-href-from-url'
import { invalidateCacheBelowFlightSegmentPath } from '../invalidate-cache-below-flight-segmentpath' import { invalidateCacheBelowFlightSegmentPath } from '../invalidate-cache-below-flight-segmentpath'
import { applyRouterStatePatchToTreeSkipDefault } from '../apply-router-state-patch-to-tree' import { applyRouterStatePatchToTree } from '../apply-router-state-patch-to-tree'
import { shouldHardNavigate } from '../should-hard-navigate' import { shouldHardNavigate } from '../should-hard-navigate'
import { isNavigatingToNewRootLayout } from '../is-navigating-to-new-root-layout' import { isNavigatingToNewRootLayout } from '../is-navigating-to-new-root-layout'
import { import {
@ -165,21 +165,23 @@ function navigateReducer_noPPR(
const flightSegmentPathWithLeadingEmpty = ['', ...flightSegmentPath] const flightSegmentPathWithLeadingEmpty = ['', ...flightSegmentPath]
// Create new tree based on the flightSegmentPath and router state patch // Create new tree based on the flightSegmentPath and router state patch
let newTree = applyRouterStatePatchToTreeSkipDefault( let newTree = applyRouterStatePatchToTree(
// TODO-APP: remove '' // TODO-APP: remove ''
flightSegmentPathWithLeadingEmpty, flightSegmentPathWithLeadingEmpty,
currentTree, currentTree,
treePatch treePatch,
url.pathname
) )
// If the tree patch can't be applied to the current tree then we use the tree at time of prefetch // If the tree patch can't be applied to the current tree then we use the tree at time of prefetch
// TODO-APP: This should instead fill in the missing pieces in `currentTree` with the data from `treeAtTimeOfPrefetch`, then apply the patch. // TODO-APP: This should instead fill in the missing pieces in `currentTree` with the data from `treeAtTimeOfPrefetch`, then apply the patch.
if (newTree === null) { if (newTree === null) {
newTree = applyRouterStatePatchToTreeSkipDefault( newTree = applyRouterStatePatchToTree(
// TODO-APP: remove '' // TODO-APP: remove ''
flightSegmentPathWithLeadingEmpty, flightSegmentPathWithLeadingEmpty,
treeAtTimeOfPrefetch, treeAtTimeOfPrefetch,
treePatch treePatch,
url.pathname
) )
} }
@ -335,21 +337,23 @@ function navigateReducer_PPR(
const flightSegmentPathWithLeadingEmpty = ['', ...flightSegmentPath] const flightSegmentPathWithLeadingEmpty = ['', ...flightSegmentPath]
// Create new tree based on the flightSegmentPath and router state patch // Create new tree based on the flightSegmentPath and router state patch
let newTree = applyRouterStatePatchToTreeSkipDefault( let newTree = applyRouterStatePatchToTree(
// TODO-APP: remove '' // TODO-APP: remove ''
flightSegmentPathWithLeadingEmpty, flightSegmentPathWithLeadingEmpty,
currentTree, currentTree,
treePatch treePatch,
url.pathname
) )
// If the tree patch can't be applied to the current tree then we use the tree at time of prefetch // If the tree patch can't be applied to the current tree then we use the tree at time of prefetch
// TODO-APP: This should instead fill in the missing pieces in `currentTree` with the data from `treeAtTimeOfPrefetch`, then apply the patch. // TODO-APP: This should instead fill in the missing pieces in `currentTree` with the data from `treeAtTimeOfPrefetch`, then apply the patch.
if (newTree === null) { if (newTree === null) {
newTree = applyRouterStatePatchToTreeSkipDefault( newTree = applyRouterStatePatchToTree(
// TODO-APP: remove '' // TODO-APP: remove ''
flightSegmentPathWithLeadingEmpty, flightSegmentPathWithLeadingEmpty,
treeAtTimeOfPrefetch, treeAtTimeOfPrefetch,
treePatch treePatch,
url.pathname
) )
} }
@ -384,8 +388,8 @@ function navigateReducer_PPR(
// version of the next page. This can be rendered instantly. // version of the next page. This can be rendered instantly.
// Use the tree computed by updateCacheNodeOnNavigation instead // Use the tree computed by updateCacheNodeOnNavigation instead
// of the one computed by applyRouterStatePatchToTreeSkipDefault. // of the one computed by applyRouterStatePatchToTree.
// TODO: We should remove applyRouterStatePatchToTreeSkipDefault // TODO: We should remove applyRouterStatePatchToTree
// from the PPR path entirely. // from the PPR path entirely.
const patchedRouterState: FlightRouterState = task.route const patchedRouterState: FlightRouterState = task.route
newTree = patchedRouterState newTree = patchedRouterState

View file

@ -1,6 +1,6 @@
import { fetchServerResponse } from '../fetch-server-response' import { fetchServerResponse } from '../fetch-server-response'
import { createHrefFromUrl } from '../create-href-from-url' import { createHrefFromUrl } from '../create-href-from-url'
import { applyRouterStatePatchToFullTree } from '../apply-router-state-patch-to-tree' import { applyRouterStatePatchToTree } from '../apply-router-state-patch-to-tree'
import { isNavigatingToNewRootLayout } from '../is-navigating-to-new-root-layout' import { isNavigatingToNewRootLayout } from '../is-navigating-to-new-root-layout'
import type { import type {
Mutable, Mutable,
@ -15,6 +15,7 @@ import { fillLazyItemsTillLeafWithHead } from '../fill-lazy-items-till-leaf-with
import { createEmptyCacheNode } from '../../app-router' import { createEmptyCacheNode } from '../../app-router'
import { handleSegmentMismatch } from '../handle-segment-mismatch' import { handleSegmentMismatch } from '../handle-segment-mismatch'
import { hasInterceptionRouteInCurrentTree } from './has-interception-route-in-current-tree' import { hasInterceptionRouteInCurrentTree } from './has-interception-route-in-current-tree'
import { refreshInactiveParallelSegments } from '../refetch-inactive-parallel-segments'
export function refreshReducer( export function refreshReducer(
state: ReadonlyReducerState, state: ReadonlyReducerState,
@ -44,7 +45,7 @@ export function refreshReducer(
) )
return cache.lazyData.then( return cache.lazyData.then(
([flightData, canonicalUrlOverride]) => { async ([flightData, canonicalUrlOverride]) => {
// Handle case when navigating to page in `pages` from `app` // Handle case when navigating to page in `pages` from `app`
if (typeof flightData === 'string') { if (typeof flightData === 'string') {
return handleExternalUrl( return handleExternalUrl(
@ -68,11 +69,12 @@ export function refreshReducer(
// Given the path can only have two items the items are only the router state and rsc for the root. // Given the path can only have two items the items are only the router state and rsc for the root.
const [treePatch] = flightDataPath const [treePatch] = flightDataPath
const newTree = applyRouterStatePatchToFullTree( const newTree = applyRouterStatePatchToTree(
// TODO-APP: remove '' // TODO-APP: remove ''
[''], [''],
currentTree, currentTree,
treePatch treePatch,
location.pathname
) )
if (newTree === null) { if (newTree === null) {
@ -112,10 +114,17 @@ export function refreshReducer(
cacheNodeSeedData, cacheNodeSeedData,
head head
) )
mutable.cache = cache
mutable.prefetchCache = new Map() mutable.prefetchCache = new Map()
} }
await refreshInactiveParallelSegments({
state,
updatedTree: newTree,
updatedCache: cache,
includeNextUrl,
})
mutable.cache = cache
mutable.patchedTree = newTree mutable.patchedTree = newTree
mutable.canonicalUrl = href mutable.canonicalUrl = href

View file

@ -31,7 +31,7 @@ import type {
import { addBasePath } from '../../../add-base-path' import { addBasePath } from '../../../add-base-path'
import { createHrefFromUrl } from '../create-href-from-url' import { createHrefFromUrl } from '../create-href-from-url'
import { handleExternalUrl } from './navigate-reducer' import { handleExternalUrl } from './navigate-reducer'
import { applyRouterStatePatchToFullTree } from '../apply-router-state-patch-to-tree' import { applyRouterStatePatchToTree } from '../apply-router-state-patch-to-tree'
import { isNavigatingToNewRootLayout } from '../is-navigating-to-new-root-layout' import { isNavigatingToNewRootLayout } from '../is-navigating-to-new-root-layout'
import type { CacheNode } from '../../../../shared/lib/app-router-context.shared-runtime' import type { CacheNode } from '../../../../shared/lib/app-router-context.shared-runtime'
import { handleMutable } from '../handle-mutable' import { handleMutable } from '../handle-mutable'
@ -39,6 +39,7 @@ import { fillLazyItemsTillLeafWithHead } from '../fill-lazy-items-till-leaf-with
import { createEmptyCacheNode } from '../../app-router' import { createEmptyCacheNode } from '../../app-router'
import { hasInterceptionRouteInCurrentTree } from './has-interception-route-in-current-tree' import { hasInterceptionRouteInCurrentTree } from './has-interception-route-in-current-tree'
import { handleSegmentMismatch } from '../handle-segment-mismatch' import { handleSegmentMismatch } from '../handle-segment-mismatch'
import { refreshInactiveParallelSegments } from '../refetch-inactive-parallel-segments'
type FetchServerActionResult = { type FetchServerActionResult = {
redirectLocation: URL | undefined redirectLocation: URL | undefined
@ -53,17 +54,11 @@ type FetchServerActionResult = {
async function fetchServerAction( async function fetchServerAction(
state: ReadonlyReducerState, state: ReadonlyReducerState,
nextUrl: ReadonlyReducerState['nextUrl'],
{ actionId, actionArgs }: ServerActionAction { actionId, actionArgs }: ServerActionAction
): Promise<FetchServerActionResult> { ): Promise<FetchServerActionResult> {
const body = await encodeReply(actionArgs) const body = await encodeReply(actionArgs)
// only pass along the `nextUrl` param (used for interception routes) if the current route was intercepted.
// If the route has been intercepted, the action should be as well.
// Otherwise the server action might be intercepted with the wrong action id
// (ie, one that corresponds with the intercepted route)
const includeNextUrl =
state.nextUrl && hasInterceptionRouteInCurrentTree(state.tree)
const res = await fetch('', { const res = await fetch('', {
method: 'POST', method: 'POST',
headers: { headers: {
@ -75,9 +70,9 @@ async function fetchServerAction(
'x-deployment-id': process.env.NEXT_DEPLOYMENT_ID, 'x-deployment-id': process.env.NEXT_DEPLOYMENT_ID,
} }
: {}), : {}),
...(includeNextUrl ...(nextUrl
? { ? {
[NEXT_URL]: state.nextUrl, [NEXT_URL]: nextUrl,
} }
: {}), : {}),
}, },
@ -162,10 +157,24 @@ export function serverActionReducer(
let currentTree = state.tree let currentTree = state.tree
mutable.preserveCustomHistoryState = false mutable.preserveCustomHistoryState = false
mutable.inFlightServerAction = fetchServerAction(state, action)
// only pass along the `nextUrl` param (used for interception routes) if the current route was intercepted.
// If the route has been intercepted, the action should be as well.
// Otherwise the server action might be intercepted with the wrong action id
// (ie, one that corresponds with the intercepted route)
const nextUrl =
state.nextUrl && hasInterceptionRouteInCurrentTree(state.tree)
? state.nextUrl
: null
mutable.inFlightServerAction = fetchServerAction(state, nextUrl, action)
return mutable.inFlightServerAction.then( return mutable.inFlightServerAction.then(
({ actionResult, actionFlightData: flightData, redirectLocation }) => { async ({
actionResult,
actionFlightData: flightData,
redirectLocation,
}) => {
// Make sure the redirection is a push instead of a replace. // Make sure the redirection is a push instead of a replace.
// Issue: https://github.com/vercel/next.js/issues/53911 // Issue: https://github.com/vercel/next.js/issues/53911
if (redirectLocation) { if (redirectLocation) {
@ -211,11 +220,12 @@ export function serverActionReducer(
// Given the path can only have two items the items are only the router state and rsc for the root. // Given the path can only have two items the items are only the router state and rsc for the root.
const [treePatch] = flightDataPath const [treePatch] = flightDataPath
const newTree = applyRouterStatePatchToFullTree( const newTree = applyRouterStatePatchToTree(
// TODO-APP: remove '' // TODO-APP: remove ''
[''], [''],
currentTree, currentTree,
treePatch treePatch,
location.pathname
) )
if (newTree === null) { if (newTree === null) {
@ -248,6 +258,14 @@ export function serverActionReducer(
cacheNodeSeedData, cacheNodeSeedData,
head head
) )
await refreshInactiveParallelSegments({
state,
updatedTree: newTree,
updatedCache: cache,
includeNextUrl: Boolean(nextUrl),
})
mutable.cache = cache mutable.cache = cache
mutable.prefetchCache = new Map() mutable.prefetchCache = new Map()
} }

View file

@ -1,5 +1,5 @@
import { createHrefFromUrl } from '../create-href-from-url' import { createHrefFromUrl } from '../create-href-from-url'
import { applyRouterStatePatchToTreeSkipDefault } from '../apply-router-state-patch-to-tree' import { applyRouterStatePatchToTree } from '../apply-router-state-patch-to-tree'
import { isNavigatingToNewRootLayout } from '../is-navigating-to-new-root-layout' import { isNavigatingToNewRootLayout } from '../is-navigating-to-new-root-layout'
import type { import type {
ServerPatchAction, ServerPatchAction,
@ -43,11 +43,12 @@ export function serverPatchReducer(
const flightSegmentPath = flightDataPath.slice(0, -4) const flightSegmentPath = flightDataPath.slice(0, -4)
const [treePatch] = flightDataPath.slice(-3, -2) const [treePatch] = flightDataPath.slice(-3, -2)
const newTree = applyRouterStatePatchToTreeSkipDefault( const newTree = applyRouterStatePatchToTree(
// TODO-APP: remove '' // TODO-APP: remove ''
['', ...flightSegmentPath], ['', ...flightSegmentPath],
currentTree, currentTree,
treePatch treePatch,
location.pathname
) )
if (newTree === null) { if (newTree === null) {

View file

@ -0,0 +1,115 @@
import type { FlightRouterState } from '../../../server/app-render/types'
import type { CacheNode } from '../../../shared/lib/app-router-context.shared-runtime'
import type { AppRouterState } from './router-reducer-types'
import { applyFlightData } from './apply-flight-data'
import { fetchServerResponse } from './fetch-server-response'
import { PAGE_SEGMENT_KEY } from '../../../shared/lib/segment'
interface RefreshInactiveParallelSegments {
state: AppRouterState
updatedTree: FlightRouterState
updatedCache: CacheNode
includeNextUrl: boolean
}
/**
* Refreshes inactive segments that are still in the current FlightRouterState.
* A segment is considered "inactive" when the server response indicates it didn't match to a page component.
* This happens during a soft-navigation, where the server will want to patch in the segment
* with the "default" component, but we explicitly ignore the server in this case
* and keep the existing state for that segment. New data for inactive segments are inherently
* not part of the server response when we patch the tree, because they were associated with a response
* from an earlier navigation/request. For each segment, once it becomes "active", we encode the URL that provided
* the data for it. This function traverses parallel routes looking for these markers so that it can re-fetch
* and patch the new data into the tree.
*/
export async function refreshInactiveParallelSegments(
options: RefreshInactiveParallelSegments
) {
const fetchedSegments = new Set<string>()
await refreshInactiveParallelSegmentsImpl({ ...options, fetchedSegments })
}
async function refreshInactiveParallelSegmentsImpl({
state,
updatedTree,
updatedCache,
includeNextUrl,
fetchedSegments,
}: RefreshInactiveParallelSegments & { fetchedSegments: Set<string> }) {
const [, parallelRoutes, refetchPathname, refetchMarker] = updatedTree
const fetchPromises = []
if (
refetchPathname &&
refetchPathname !== location.pathname &&
refetchMarker === 'refresh' &&
// it's possible for the tree to contain multiple segments that contain data at the same URL
// we keep track of them so we can dedupe the requests
!fetchedSegments.has(refetchPathname)
) {
fetchedSegments.add(refetchPathname) // Mark this URL as fetched
// Eagerly kick off the fetch for the refetch path & the parallel routes. This should be fine to do as they each operate
// independently on their own cache nodes, and `applyFlightData` will copy anything it doesn't care about from the existing cache.
const fetchPromise = fetchServerResponse(
// we capture the pathname of the refetch without search params, so that it can be refetched with
// the "latest" search params when it comes time to actually trigger the fetch (below)
new URL(refetchPathname + location.search, location.origin),
[updatedTree[0], updatedTree[1], updatedTree[2], 'refetch'],
includeNextUrl ? state.nextUrl : null,
state.buildId
).then((fetchResponse) => {
const flightData = fetchResponse[0]
if (typeof flightData !== 'string') {
for (const flightDataPath of flightData) {
// we only pass the new cache as this function is called after clearing the router cache
// and filling in the new page data from the server. Meaning the existing cache is actually the cache that's
// just been created & has been written to, but hasn't been "committed" yet.
applyFlightData(updatedCache, updatedCache, flightDataPath)
}
} else {
// When flightData is a string, it suggests that the server response should have triggered an MPA navigation
// I'm not 100% sure of this decision, but it seems unlikely that we'd want to introduce a redirect side effect
// when refreshing on-screen data, so handling this has been ommitted.
}
})
fetchPromises.push(fetchPromise)
}
for (const key in parallelRoutes) {
const parallelFetchPromise = refreshInactiveParallelSegmentsImpl({
state,
updatedTree: parallelRoutes[key],
updatedCache,
includeNextUrl,
fetchedSegments,
})
fetchPromises.push(parallelFetchPromise)
}
await Promise.all(fetchPromises)
}
/**
* Walks the current parallel segments to determine if they are "active".
* An active parallel route will have a `__PAGE__` segment in the FlightRouterState.
* As opposed to a `__DEFAULT__` segment, which means there was no match for that parallel route.
* We add a special marker here so that we know how to refresh its data when the router is revalidated.
*/
export function addRefreshMarkerToActiveParallelSegments(
tree: FlightRouterState,
pathname: string
) {
const [segment, parallelRoutes, , refetchMarker] = tree
if (segment === PAGE_SEGMENT_KEY && refetchMarker !== 'refresh') {
tree[2] = pathname
tree[3] = 'refresh'
}
for (const key in parallelRoutes) {
addRefreshMarkerToActiveParallelSegments(parallelRoutes[key], pathname)
}
}

View file

@ -38,7 +38,7 @@ export const flightRouterStateSchema: s.Describe<any> = s.tuple([
s.lazy(() => flightRouterStateSchema) s.lazy(() => flightRouterStateSchema)
), ),
s.optional(s.nullable(s.string())), s.optional(s.nullable(s.string())),
s.optional(s.nullable(s.literal('refetch'))), s.optional(s.nullable(s.union([s.literal('refetch'), s.literal('refresh')]))),
s.optional(s.boolean()), s.optional(s.boolean()),
]) ])
@ -49,7 +49,13 @@ export type FlightRouterState = [
segment: Segment, segment: Segment,
parallelRoutes: { [parallelRouterKey: string]: FlightRouterState }, parallelRoutes: { [parallelRouterKey: string]: FlightRouterState },
url?: string | null, url?: string | null,
refresh?: 'refetch' | null, /*
/* "refresh" and "refetch", despite being similarly named, have different semantics.
* - "refetch" is a server indicator which informs where rendering should start from.
* - "refresh" is a client router indicator that it should re-fetch the data from the server for the current segment.
* It uses the "url" property above to determine where to fetch from.
*/
refresh?: 'refetch' | 'refresh' | null,
isRootLayout?: boolean isRootLayout?: boolean
] ]

View file

@ -0,0 +1,3 @@
export default function Page() {
return null
}

View file

@ -11,6 +11,9 @@ export default function Root({
}) { }) {
return ( return (
<html> <html>
<head>
<script src="https://cdn.tailwindcss.com?plugins=typography"></script>
</head>
<body> <body>
{children} {children}
{dialog} {dialog}

View file

@ -0,0 +1,3 @@
export default function Default() {
return null
}

View file

@ -0,0 +1,54 @@
'use client'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { revalidateAction } from '../../@modal/modal/action'
export default function Page() {
const router = useRouter()
const handleRevalidateSubmit = async () => {
const result = await revalidateAction()
if (result.success) {
close()
}
}
const close = () => {
router.back()
}
return (
<div className="w-1/3 fixed right-0 top-0 bottom-0 h-screen shadow-2xl bg-gray-50 p-10">
<h2 id="drawer">Drawer</h2>
<p id="drawer-now">{Date.now()}</p>
<button
type="button"
id="drawer-close-button"
onClick={() => close()}
className="bg-gray-100 border p-2 rounded"
>
close
</button>
<p className="mt-4">Drawer</p>
<div className="mt-4 flex flex-col gap-2">
<Link
href="/nested-revalidate/modal"
className="bg-sky-600 text-white p-2 rounded"
>
Open modal
</Link>
<form action={handleRevalidateSubmit}>
<button
type="submit"
className="bg-sky-600 text-white p-2 rounded"
id="drawer-submit-button"
>
Revalidate submit
</button>
</form>
</div>
</div>
)
}

View file

@ -0,0 +1,3 @@
export default function Default() {
return null
}

View file

@ -0,0 +1,11 @@
'use server'
import { revalidatePath } from 'next/cache'
export async function revalidateAction() {
console.log('revalidate action')
revalidatePath('/')
return {
success: true,
}
}

View file

@ -0,0 +1,43 @@
'use client'
import { useRouter } from 'next/navigation'
import { revalidateAction } from './action'
export default function Page() {
const router = useRouter()
const handleRevalidateSubmit = async () => {
const result = await revalidateAction()
if (result.success) {
close()
}
}
const close = () => {
router.back()
}
return (
<div className="z-10 fixed w-96 p-5 top-20 left-0 right-0 m-auto rounded shadow-2xl bg-gray-50 border-2">
<div className="flex justify-between">
<h2 id="modal">Modal</h2>
<button
type="button"
id="modal-close-button"
onClick={() => close()}
className="bg-gray-100 border p-2 rounded"
>
close
</button>
</div>
<form action={handleRevalidateSubmit}>
<button
type="submit"
className="bg-sky-600 text-white p-2 rounded"
id="modal-submit-button"
>
Revalidate Submit
</button>
</form>
</div>
)
}

View file

@ -0,0 +1,19 @@
export const dynamic = 'force-dynamic'
export default function Layout({
children,
modal,
drawer,
}: {
children: React.ReactNode
modal: React.ReactNode
drawer: React.ReactNode
}) {
return (
<div>
<div>{children}</div>
<div>{modal}</div>
<div>{drawer}</div>
</div>
)
}

View file

@ -0,0 +1,24 @@
import Link from 'next/link'
export default function Home() {
return (
<div className="text-center h-screen flex flex-col gap-4 justify-center w-60 mx-auto">
<p>Nested parallel routes demo.</p>
<p id="page-now">Date.now {Date.now()}</p>
<div className="flex flex-col gap-2">
<Link
href="/nested-revalidate/drawer"
className="bg-sky-600 text-white p-2 rounded"
>
Open Drawer
</Link>
<Link
href="/nested-revalidate/modal"
className="bg-sky-600 text-white p-2 rounded"
>
Open Modal
</Link>
</div>
</div>
)
}

View file

@ -0,0 +1,19 @@
import { Button } from '../../buttonRefresh'
const getRandom = async () => Math.random()
export default async function Page() {
const someProp = await getRandom()
return (
<dialog open>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<div>
<span>Modal Page</span>
<span id="modal-random">{someProp}</span>
</div>
<Button />
</div>
</dialog>
)
}

View file

@ -0,0 +1,3 @@
export default function Default() {
return null
}

View file

@ -0,0 +1,16 @@
'use client'
import { useRouter } from 'next/navigation'
export function Button() {
const router = useRouter()
return (
<button
id="refresh-button"
style={{ color: 'red', padding: '10px' }}
onClick={() => router.refresh()}
>
Refresh
</button>
)
}

View file

@ -0,0 +1,19 @@
import Link from 'next/link'
export const dynamic = 'force-dynamic'
export default function Layout({
children,
modal,
}: {
children: React.ReactNode
modal: React.ReactNode
}) {
return (
<div>
<div>{children}</div>
<div>{modal}</div>
<Link href="/refreshing/other">Go to Other Page</Link>
</div>
)
}

View file

@ -0,0 +1,11 @@
import { Button } from '../buttonRefresh'
export default function Page() {
return (
<>
<span>Login Page</span>
<Button />
Random Number: <span id="login-page-random">{Math.random()}</span>
</>
)
}

View file

@ -0,0 +1,8 @@
export default function Page() {
return (
<div>
<div>Other Page</div>
<div id="other-page-random">{Math.random()}</div>
</div>
)
}

View file

@ -0,0 +1,14 @@
import Link from 'next/link'
export default function Home() {
return (
<main>
<Link href="/refreshing/login">
<button>Login button</button>
</Link>
<div>
Random # from Root Page: <span id="random-number">{Math.random()}</span>
</div>
</main>
)
}

View file

@ -157,5 +157,135 @@ createNextDescribe(
expect(await browser.elementById('params').text()).toBe('foobar') expect(await browser.elementById('params').text()).toBe('foobar')
}) })
}) })
describe('router.refresh', () => {
it('should correctly refresh data for the intercepted route and previously active page slot', async () => {
const browser = await next.browser('/refreshing')
const initialRandomNumber = await browser.elementById('random-number')
await browser.elementByCss("[href='/refreshing/login']").click()
// interception modal should be visible
const initialModalRandomNumber = await browser
.elementById('modal-random')
.text()
// trigger a refresh
await browser.elementById('refresh-button').click()
await retry(async () => {
const newRandomNumber = await browser
.elementById('random-number')
.text()
const newModalRandomNumber = await browser
.elementById('modal-random')
.text()
expect(initialRandomNumber).not.toBe(newRandomNumber)
expect(initialModalRandomNumber).not.toBe(newModalRandomNumber)
})
// reload the page, triggering which will remove the interception route and show the full page
await browser.refresh()
const initialLoginPageRandomNumber = await browser
.elementById('login-page-random')
.text()
// trigger a refresh
await browser.elementById('refresh-button').click()
await retry(async () => {
const newLoginPageRandomNumber = await browser
.elementById('login-page-random')
.text()
expect(newLoginPageRandomNumber).not.toBe(
initialLoginPageRandomNumber
)
})
})
it('should correctly refresh data for previously intercepted modal and active page slot', async () => {
const browser = await next.browser('/refreshing')
await browser.elementByCss("[href='/refreshing/login']").click()
// interception modal should be visible
const initialModalRandomNumber = await browser
.elementById('modal-random')
.text()
await browser.elementByCss("[href='/refreshing/other']").click()
// data for the /other page should be visible
const initialOtherPageRandomNumber = await browser
.elementById('other-page-random')
.text()
// trigger a refresh
await browser.elementById('refresh-button').click()
await retry(async () => {
const newModalRandomNumber = await browser
.elementById('modal-random')
.text()
const newOtherPageRandomNumber = await browser
.elementById('other-page-random')
.text()
expect(initialModalRandomNumber).not.toBe(newModalRandomNumber)
expect(initialOtherPageRandomNumber).not.toBe(
newOtherPageRandomNumber
)
})
})
})
describe('server action revalidation', () => {
it('handles refreshing when multiple parallel slots are active', async () => {
const browser = await next.browser('/nested-revalidate')
const currentPageTime = await browser.elementById('page-now').text()
expect(await browser.hasElementByCssSelector('#modal')).toBe(false)
expect(await browser.hasElementByCssSelector('#drawer')).toBe(false)
// renders the drawer parallel slot
await browser.elementByCss("[href='/nested-revalidate/drawer']").click()
await browser.waitForElementByCss('#drawer')
// renders the modal slot
await browser.elementByCss("[href='/nested-revalidate/modal']").click()
await browser.waitForElementByCss('#modal')
// Both should be visible, despite only one "matching"
expect(await browser.hasElementByCssSelector('#modal')).toBe(true)
expect(await browser.hasElementByCssSelector('#drawer')).toBe(true)
// grab the current time of the drawer
const currentDrawerTime = await browser.elementById('drawer-now').text()
// trigger the revalidation action in the modal.
await browser.elementById('modal-submit-button').click()
await retry(async () => {
// Revalidation should close the modal
expect(await browser.hasElementByCssSelector('#modal')).toBe(false)
// But the drawer should still be open
expect(await browser.hasElementByCssSelector('#drawer')).toBe(true)
// And the drawer should have a new time
expect(await browser.elementById('drawer-now').text()).not.toEqual(
currentDrawerTime
)
// And the underlying page should have a new time
expect(await browser.elementById('page-now').text()).not.toEqual(
currentPageTime
)
})
})
})
} }
) )

View file

@ -2251,7 +2251,10 @@
"failed": [ "failed": [
"parallel-routes-revalidation should handle a redirect action when called in a slot", "parallel-routes-revalidation should handle a redirect action when called in a slot",
"parallel-routes-revalidation should handle router.refresh() when called in a slot", "parallel-routes-revalidation should handle router.refresh() when called in a slot",
"parallel-routes-revalidation should not trigger full page when calling router.refresh() on an intercepted route" "parallel-routes-revalidation should not trigger full page when calling router.refresh() on an intercepted route",
"parallel-routes-revalidation router.refresh should correctly refresh data for the intercepted route and previously active page slot",
"parallel-routes-revalidation router.refresh should correctly refresh data for previously intercepted modal and active page slot",
"parallel-routes-revalidation server action revalidation handles refreshing when multiple parallel slots are active"
], ],
"pending": [], "pending": [],
"flakey": [], "flakey": [],