Skip to content

Commit 1c1dc47

Browse files
authored
fix PPR navigations when visiting route with seeded cache (vercel#67439)
During SSR, we walk the component tree and pass and immediately seed the router cache with the tree. We also seed the prefetch cache for the initial route with the same tree data because navigations events will apply the data from the prefetch cache. The rationale for this is that since we already have the data, we might as well seed it in the prefetch cache, to save a server round trip later. The way we're seeding the cache introduced a bug when PPR is turned on: PPR expects that each element in `FlightDataPath` is of length 3, corresponding with: `[PrefetchedTree, SeedData, Head]`. However, we were seeding it with 4 items: `[Segment, PrefetchedTree, SeedData, Head]`. When the forked router implementation sees anything other than 3 items, it reverts back to the pre-PPR behavior, which means the data will be lazy-fetched during render rather than eagerly in the reducer. As a result, when navigating back to the page that was seeded during SSR, it wouldn't immediately apply the static parts to the UI: it'd block until the dynamic render finished. This PR also includes a small change to ensure reused tasks don’t trigger a dynamic request. A navigation test was failing because this PR fixed a bug that was causing it to hit the non-PPR behavior, which already handles this correctly. I've added an explicit test for this in PPR. I've also excluded all of the `client-cache` tests from PPR, as many of them were already failing and aren't relevant to PPR, since PPR doesn't currently make use of the `staleTime`/ cache heuristics that the regular router does.
1 parent e8e9212 commit 1c1dc47

File tree

6 files changed

+79
-47
lines changed

6 files changed

+79
-47
lines changed

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,9 @@ export function createInitialRouterState({
105105
location.origin
106106
)
107107

108-
const initialFlightData: FlightData = [['', initialTree, null, null]]
108+
const initialFlightData: FlightData = [
109+
[initialTree, initialSeedData, initialHead],
110+
]
109111
createPrefetchCacheEntryForInitialLoad({
110112
url,
111113
kind: PrefetchKind.AUTO,

packages/next/src/client/components/router-reducer/ppr-navigations.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,8 @@ export function updateCacheNodeOnNavigation(
6767
oldRouterState: FlightRouterState,
6868
newRouterState: FlightRouterState,
6969
prefetchData: CacheNodeSeedData,
70-
prefetchHead: React.ReactNode
70+
prefetchHead: React.ReactNode,
71+
onlyHashChange: boolean
7172
): Task | null {
7273
// Diff the old and new trees to reuse the shared layouts.
7374
const oldRouterStateChildren = oldRouterState[1]
@@ -98,6 +99,17 @@ export function updateCacheNodeOnNavigation(
9899
[parallelRouteKey: string]: FlightRouterState
99100
} = {}
100101
let taskChildren = null
102+
103+
// For most navigations, we need to issue a "dynamic" request to fetch the
104+
// full RSC data from the server since during rendering, we'll only serve
105+
// the prefetch shell. For some navigations, we re-use the existing cache node
106+
// (via `spawnReusedTask`), and don't actually need fresh data from the server.
107+
// In those cases, we use this `needsDynamicRequest` flag to return a `null`
108+
// cache node, which signals to the caller that we don't need to issue a
109+
// dynamic request. We start off with a `false` value, and then for each parallel
110+
// route, we set it to `true` if we encounter a segment that needs a dynamic request.
111+
let needsDynamicRequest = false
112+
101113
for (let parallelRouteKey in newRouterStateChildren) {
102114
const newRouterStateChild: FlightRouterState =
103115
newRouterStateChildren[parallelRouteKey]
@@ -119,7 +131,12 @@ export function updateCacheNodeOnNavigation(
119131
: undefined
120132

121133
let taskChild: Task | null
122-
if (newSegmentChild === PAGE_SEGMENT_KEY) {
134+
if (onlyHashChange && oldRouterStateChild !== undefined) {
135+
// If only the hash fragment changed, we can re-use the existing cache.
136+
// We spawn a "task" just to keep track of the updated router state; unlike most, it's
137+
// already fulfilled and won't be affected by the dynamic response.
138+
taskChild = spawnReusedTask(oldRouterStateChild)
139+
} else if (newSegmentChild === PAGE_SEGMENT_KEY) {
123140
// This is a leaf segment — a page, not a shared layout. We always apply
124141
// its data.
125142
taskChild = spawnPendingTask(
@@ -164,7 +181,8 @@ export function updateCacheNodeOnNavigation(
164181
oldRouterStateChild,
165182
newRouterStateChild,
166183
prefetchDataChild,
167-
prefetchHead
184+
prefetchHead,
185+
onlyHashChange
168186
)
169187
} else {
170188
// The server didn't send any prefetch data for this segment. This
@@ -204,6 +222,9 @@ export function updateCacheNodeOnNavigation(
204222
const newSegmentMapChild: ChildSegmentMap = new Map(oldSegmentMapChild)
205223
newSegmentMapChild.set(newSegmentKeyChild, newCacheNodeChild)
206224
prefetchParallelRoutes.set(parallelRouteKey, newSegmentMapChild)
225+
// a non-null taskChild.node means we're waiting for a dynamic request to
226+
// fill in the missing data
227+
needsDynamicRequest = true
207228
}
208229

209230
// The child tree's route state may be different from the prefetched
@@ -245,7 +266,9 @@ export function updateCacheNodeOnNavigation(
245266
newRouterState,
246267
patchedRouterStateChildren
247268
),
248-
node: newCacheNode,
269+
// Only return the new cache node if there are pending tasks that need to be resolved
270+
// by the dynamic data from the server. If they don't, we don't need to trigger a dynamic request.
271+
node: needsDynamicRequest ? newCacheNode : null,
249272
children: taskChildren,
250273
}
251274
}

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

Lines changed: 32 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -417,9 +417,10 @@ function navigateReducer_PPR(
417417
currentTree,
418418
prefetchedTree,
419419
seedData,
420-
head
420+
head,
421+
mutable.onlyHashChange
421422
)
422-
if (task !== null && task.node !== null) {
423+
if (task !== null) {
423424
// We've created a new Cache Node tree that contains a prefetched
424425
// version of the next page. This can be rendered instantly.
425426

@@ -430,32 +431,37 @@ function navigateReducer_PPR(
430431
const patchedRouterState: FlightRouterState = task.route
431432
newTree = patchedRouterState
432433

433-
const newCache = task.node
434-
435-
// The prefetched tree has dynamic holes in it. We initiate a
436-
// dynamic request to fill them in.
437-
//
438-
// Do not block on the result. We'll immediately render the Cache
439-
// Node tree and suspend on the dynamic parts. When the request
440-
// comes in, we'll fill in missing data and ping React to
441-
// re-render. Unlike the lazy fetching model in the non-PPR
442-
// implementation, this is modeled as a single React update +
443-
// streaming, rather than multiple top-level updates. (However,
444-
// even in the new model, we'll still need to sometimes update the
445-
// root multiple times per navigation, like if the server sends us
446-
// a different response than we expected. For now, we revert back
447-
// to the lazy fetching mechanism in that case.)
448-
listenForDynamicRequest(
449-
task,
450-
fetchServerResponse(
451-
url,
452-
currentTree,
453-
state.nextUrl,
454-
state.buildId
434+
// It's possible that `updateCacheNodeOnNavigation` only spawned tasks to reuse the existing cache,
435+
// in which case `task.node` will be null, signaling we don't need to wait for a dynamic request
436+
// and can simply apply the patched `FlightRouterState`.
437+
if (task.node !== null) {
438+
const newCache = task.node
439+
440+
// The prefetched tree has dynamic holes in it. We initiate a
441+
// dynamic request to fill them in.
442+
//
443+
// Do not block on the result. We'll immediately render the Cache
444+
// Node tree and suspend on the dynamic parts. When the request
445+
// comes in, we'll fill in missing data and ping React to
446+
// re-render. Unlike the lazy fetching model in the non-PPR
447+
// implementation, this is modeled as a single React update +
448+
// streaming, rather than multiple top-level updates. (However,
449+
// even in the new model, we'll still need to sometimes update the
450+
// root multiple times per navigation, like if the server sends us
451+
// a different response than we expected. For now, we revert back
452+
// to the lazy fetching mechanism in that case.)
453+
listenForDynamicRequest(
454+
task,
455+
fetchServerResponse(
456+
url,
457+
currentTree,
458+
state.nextUrl,
459+
state.buildId
460+
)
455461
)
456-
)
457462

458-
mutable.cache = newCache
463+
mutable.cache = newCache
464+
}
459465
} else {
460466
// Nothing changed, so reuse the old cache.
461467
// TODO: What if the head changed but not any of the segment data?

test/e2e/app-dir/app/index.test.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -717,9 +717,11 @@ describe('app dir - basic', () => {
717717
await browser.waitForElementByCss('#render-id')
718718
expect(await browser.eval('window.history.length')).toBe(2)
719719

720-
// Get the ID again, and compare, they should be the same.
721-
const thirdID = await browser.elementById('render-id').text()
722-
expect(thirdID).not.toBe(firstID)
720+
await retry(async () => {
721+
// Get the ID again, and compare, they should be the same.
722+
const thirdID = await browser.elementById('render-id').text()
723+
expect(thirdID).not.toBe(firstID)
724+
})
723725

724726
// verify that the flag is still set
725727
expect(await browser.eval('window.__nextSoftPushTest')).toBe(1)

test/e2e/app-dir/ppr-navigations/prefetch-navigation/prefetch-navigation.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,5 +62,14 @@ describe('prefetch-navigation', () => {
6262

6363
// Resolve the request to allow the page to finish loading
6464
await rscRequestPromise.get('/catch-all/2').resolve()
65+
66+
// Now go back to the first page to make sure that the server seeded
67+
// prefetch cache entry behaves the same as one that is retrieved
68+
// from the client prefetch.
69+
await browser.elementByCss('a[href="/catch-all/1"]').click()
70+
await browser.waitForElementByCss('#dynamic-page-1')
71+
72+
targetPageParams = await browser.elementById('params').text()
73+
expect(targetPageParams).toBe('Params: {"slug":["1"]}')
6574
})
6675
})

test/ppr-tests-manifest.json

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,6 @@
2020
"app-dir static/dynamic handling should output debug info for static bailouts"
2121
]
2222
},
23-
"test/e2e/app-dir/app-client-cache/client-cache.original.test.ts": {
24-
"failed": [
25-
"app dir client cache semantics (30s/5min) prefetch={undefined} - default should re-use the full cache for only 30 seconds",
26-
"app dir client cache semantics (30s/5min) prefetch={undefined} - default should renew the 30s cache once the data is revalidated",
27-
"app dir client cache semantics (30s/5min) prefetch={undefined} - default should refetch the full page after 5 mins"
28-
]
29-
},
30-
"test/e2e/app-dir/app-client-cache/client-cache.defaults.test.ts": {
31-
"failed": [
32-
"app dir client cache semantics (default semantics) prefetch={undefined} - default should refetch the full page after 5 mins"
33-
]
34-
},
3523
"test/e2e/app-dir/headers-static-bailout/headers-static-bailout.test.ts": {
3624
"failed": [
3725
"headers-static-bailout it provides a helpful link in case static generation bailout is uncaught"
@@ -92,7 +80,9 @@
9280
"test/e2e/app-dir/ppr-*/**/*",
9381
"test/e2e/app-dir/app-prefetch*/**/*",
9482
"test/e2e/app-dir/searchparams-static-bailout/searchparams-static-bailout.test.ts",
95-
"test/e2e/app-dir/app-client-cache/client-cache.experimental.test.ts"
83+
"test/e2e/app-dir/app-client-cache/client-cache.experimental.test.ts",
84+
"test/e2e/app-dir/app-client-cache/client-cache.original.test.ts",
85+
"test/e2e/app-dir/app-client-cache/client-cache.defaults.test.ts"
9686
]
9787
}
9888
}

0 commit comments

Comments
 (0)