-
Notifications
You must be signed in to change notification settings - Fork 26.1k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
fix revalidation/refresh behavior with parallel routes (#63608)
### What When a parallel segment in the current router tree is no longer "active" during a soft navigation (ie, no longer matches a page component on the particular route), it remains on-screen until the page is refreshed, at which point it would switch to rendering the `default.tsx` component. However, when revalidating the router cache via `router.refresh`, or when a server action finishes & refreshes the router cache, this would trigger the "hard refresh" behavior. This would have the unintended consequence of a 404 being triggered (which is the default behavior of `default.tsx`) or inactive segments disappearing unexpectedly. ### Why When the router cache is refreshed, it currently fetches new data for the page by fetching from the current URL the user is on. This means that the server will never respond with the data it needs if the segment wasn't "activated" via the URL we're fetching from, as it came from someplace else. Instead, the server will give us data for the `default.tsx` component, which we don't want to render when doing a soft refresh. ### How This updates the `FlightRouterState` to encode information about the URL that caused the segment to become active. That way, when some sort of revalidation event takes place, we can both refresh the data for the current URL (existing handling), and recursively refetch segment data for anything that was still present in the tree but requires fetching from a different URL. We patch this new data into the tree before committing the final `CacheNode` to the router. **Note**: I re-used the existing `refresh` and `url` arguments in `FlightRouterState` to avoid introducing more options to this data structure that is already a bit tricky to work with. Initially I was going to re-use `"refetch"` as-is, which seemed to work ok, but I'm worried about potential implications of this considering they have different semantics. In an abundance of caution, I added a new marker type ("`refresh`", alternative suggestions welcome). This has some trade-offs: namely, if there are a lot of different segments that are in this stale state that require data from different URLs, the refresh is going to be blocked while we fetch all of these segments. Having to do a separate round-trip for each of these segments could be expensive. In an ideal world, we'd be able to enumerate the segments we'd want to refetch and where they came from, so it could be handled in a single round-trip. There are some ideas on how to improve per-segment fetching which are out of scope of this PR. However, due to the implicit contract that `middleware.ts` creates with URLs, we still need to identify these resources by URLs. Fixes #60815 Fixes #60950 Fixes #51711 Fixes #51714 Fixes #58715 Fixes #60948 Fixes #62213 Fixes #61341 Closes NEXT-1845 Closes NEXT-2030
- Loading branch information
Showing
24 changed files
with
557 additions
and
19 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
115 changes: 115 additions & 0 deletions
115
packages/next/src/client/components/router-reducer/refetch-inactive-parallel-segments.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
3 changes: 3 additions & 0 deletions
3
test/e2e/app-dir/parallel-routes-revalidation/app/@interception/page.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
export default function Page() { | ||
return null | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
3 changes: 3 additions & 0 deletions
3
test/e2e/app-dir/parallel-routes-revalidation/app/nested-revalidate/@drawer/default.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
export default function Default() { | ||
return null | ||
} |
54 changes: 54 additions & 0 deletions
54
test/e2e/app-dir/parallel-routes-revalidation/app/nested-revalidate/@drawer/drawer/page.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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> | ||
) | ||
} |
3 changes: 3 additions & 0 deletions
3
test/e2e/app-dir/parallel-routes-revalidation/app/nested-revalidate/@modal/default.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
export default function Default() { | ||
return null | ||
} |
11 changes: 11 additions & 0 deletions
11
test/e2e/app-dir/parallel-routes-revalidation/app/nested-revalidate/@modal/modal/action.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
'use server' | ||
|
||
import { revalidatePath } from 'next/cache' | ||
|
||
export async function revalidateAction() { | ||
console.log('revalidate action') | ||
revalidatePath('/') | ||
return { | ||
success: true, | ||
} | ||
} |
Oops, something went wrong.