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:
parent
5a1409179c
commit
68de4c0357
28 changed files with 607 additions and 90 deletions
|
@ -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([
|
||||||
|
|
|
@ -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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
export default function Page() {
|
||||||
|
return null
|
||||||
|
}
|
|
@ -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}
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
export default function Default() {
|
||||||
|
return null
|
||||||
|
}
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
export default function Default() {
|
||||||
|
return null
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
'use server'
|
||||||
|
|
||||||
|
import { revalidatePath } from 'next/cache'
|
||||||
|
|
||||||
|
export async function revalidateAction() {
|
||||||
|
console.log('revalidate action')
|
||||||
|
revalidatePath('/')
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
export default function Default() {
|
||||||
|
return null
|
||||||
|
}
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div>Other Page</div>
|
||||||
|
<div id="other-page-random">{Math.random()}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
@ -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": [],
|
||||||
|
|
Loading…
Reference in a new issue