Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix instant loading states after invalidating prefetch cache #63256

Merged
merged 1 commit into from
Mar 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,18 +1,9 @@
import React from 'react'
import type { FetchServerResponseResult } from './fetch-server-response'
import { fillCacheWithDataProperty } from './fill-cache-with-data-property'
import { clearCacheNodeDataForSegmentPath } from './clear-cache-node-data-for-segment-path'
import type { CacheNode } from '../../../shared/lib/app-router-context.shared-runtime'

describe('fillCacheWithDataProperty', () => {
it('should add data property', () => {
const fetchServerResponseMock: jest.Mock<
Promise<FetchServerResponseResult>
> = jest.fn(() =>
Promise.resolve([
/* TODO-APP: replace with actual FlightData */ '',
undefined,
])
)
describe('clearCacheNodeDataForSegmentPath', () => {
it('should clear the data property', () => {
const pathname = '/dashboard/settings'
const segments = pathname.split('/')

Expand Down Expand Up @@ -80,9 +71,7 @@ describe('fillCacheWithDataProperty', () => {
]),
}

fillCacheWithDataProperty(cache, existingCache, flightSegmentPath, () =>
fetchServerResponseMock()
)
clearCacheNodeDataForSegmentPath(cache, existingCache, flightSegmentPath)

expect(cache).toMatchInlineSnapshot(`
{
Expand Down Expand Up @@ -121,7 +110,7 @@ describe('fillCacheWithDataProperty', () => {
},
"dashboard" => {
"head": null,
"lazyData": Promise {},
"lazyData": null,
"lazyDataResolved": false,
"loading": null,
"parallelRoutes": Map {},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
import type { FetchServerResponseResult } from './fetch-server-response'
import type { FlightSegmentPath } from '../../../server/app-render/types'
import type { CacheNode } from '../../../shared/lib/app-router-context.shared-runtime'
import { createRouterCacheKey } from './create-router-cache-key'

/**
* Kick off fetch based on the common layout between two routes. Fill cache with data property holding the in-progress fetch.
* This will clear the CacheNode data for a particular segment path. This will cause a lazy-fetch in layout router to fill in new data.
*/
export function fillCacheWithDataProperty(
export function clearCacheNodeDataForSegmentPath(
newCache: CacheNode,
existingCache: CacheNode,
flightSegmentPath: FlightSegmentPath,
fetchResponse: () => Promise<FetchServerResponseResult>
flightSegmentPath: FlightSegmentPath
): void {
const isLastEntry = flightSegmentPath.length <= 2

Expand Down Expand Up @@ -38,7 +36,7 @@ export function fillCacheWithDataProperty(
childCacheNode === existingChildCacheNode
) {
childSegmentMap.set(cacheKey, {
lazyData: fetchResponse(),
lazyData: null,
rsc: null,
prefetchRsc: null,
head: null,
Expand All @@ -55,7 +53,7 @@ export function fillCacheWithDataProperty(
// Start fetch in the place where the existing cache doesn't have the data yet.
if (!childCacheNode) {
childSegmentMap.set(cacheKey, {
lazyData: fetchResponse(),
lazyData: null,
rsc: null,
prefetchRsc: null,
head: null,
Expand All @@ -82,10 +80,9 @@ export function fillCacheWithDataProperty(
childSegmentMap.set(cacheKey, childCacheNode)
}

return fillCacheWithDataProperty(
return clearCacheNodeDataForSegmentPath(
childCacheNode,
existingChildCacheNode,
flightSegmentPath.slice(2),
fetchResponse
flightSegmentPath.slice(2)
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,7 @@ export function getOrCreatePrefetchCacheEntry({
existingCacheEntry.kind !== PrefetchKind.FULL &&
kind === PrefetchKind.FULL

// If the cache entry isn't reusable, rather than returning it, we want to create a new entry.
const hasReusablePrefetch =
existingCacheEntry.status === PrefetchCacheEntryStatus.reusable ||
existingCacheEntry.status === PrefetchCacheEntryStatus.fresh

if (switchedToFullPrefetch || !hasReusablePrefetch) {
if (switchedToFullPrefetch) {
return createLazyPrefetchEntry({
tree,
url,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ import { invalidateCacheBelowFlightSegmentPath } from '../invalidate-cache-below
import { applyRouterStatePatchToTreeSkipDefault } from '../apply-router-state-patch-to-tree'
import { shouldHardNavigate } from '../should-hard-navigate'
import { isNavigatingToNewRootLayout } from '../is-navigating-to-new-root-layout'
import type {
Mutable,
NavigateAction,
ReadonlyReducerState,
ReducerState,
import {
PrefetchCacheEntryStatus,
type Mutable,
type NavigateAction,
type ReadonlyReducerState,
type ReducerState,
} from '../router-reducer-types'
import { handleMutable } from '../handle-mutable'
import { applyFlightData } from '../apply-flight-data'
Expand All @@ -28,6 +29,7 @@ import {
getOrCreatePrefetchCacheEntry,
prunePrefetchCache,
} from '../prefetch-cache-utils'
import { clearCacheNodeDataForSegmentPath } from '../clear-cache-node-data-for-segment-path'

export function handleExternalUrl(
state: ReadonlyReducerState,
Expand Down Expand Up @@ -69,6 +71,32 @@ function generateSegmentsFromPatch(
return segments
}

function triggerLazyFetchForLeafSegments(
newCache: CacheNode,
currentCache: CacheNode,
flightSegmentPath: FlightSegmentPath,
treePatch: FlightRouterState
) {
let appliedPatch = false

newCache.rsc = currentCache.rsc
newCache.prefetchRsc = currentCache.prefetchRsc
newCache.loading = currentCache.loading
newCache.parallelRoutes = new Map(currentCache.parallelRoutes)

const segmentPathsToFill = generateSegmentsFromPatch(treePatch).map(
(segment) => [...flightSegmentPath, ...segment]
)

for (const segmentPaths of segmentPathsToFill) {
clearCacheNodeDataForSegmentPath(newCache, currentCache, segmentPaths)

appliedPatch = true
}

return appliedPatch
}

// These implementations are expected to diverge significantly, so I've forked
// the entire function. The one that's disabled should be dead code eliminated
// because the check here is statically inlined at build time.
Expand Down Expand Up @@ -109,10 +137,12 @@ function navigateReducer_noPPR(

return data.then(
([flightData, canonicalUrlOverride]) => {
let isFirstRead = false
// we only want to mark this once
if (!prefetchValues.lastUsedTime) {
// important: we should only mark the cache node as dirty after we unsuspend from the call above
prefetchValues.lastUsedTime = Date.now()
isFirstRead = true
}

// Handle case when navigating to page in `pages` from `app`
Expand Down Expand Up @@ -159,12 +189,33 @@ function navigateReducer_noPPR(
}

const cache: CacheNode = createEmptyCacheNode()
let applied = applyFlightData(
currentCache,
cache,
flightDataPath,
prefetchValues
)
let applied = false

if (
prefetchValues.status === PrefetchCacheEntryStatus.stale &&
!isFirstRead
) {
// When we have a stale prefetch entry, we only want to re-use the loading state of the route we're navigating to, to support instant loading navigations
// this will trigger a lazy fetch for the actual page data by nulling the `rsc` and `prefetchRsc` values for page data,
// while copying over the `loading` for the segment that contains the page data.
// We only do this on subsequent reads, as otherwise there'd be no loading data to re-use.
applied = triggerLazyFetchForLeafSegments(
cache,
currentCache,
flightSegmentPath,
treePatch
)
// since we re-used the stale cache's loading state & refreshed the data,
// update the `lastUsedTime` so that it can continue to be re-used for the next 30s
prefetchValues.lastUsedTime = Date.now()
} else {
applied = applyFlightData(
currentCache,
cache,
flightDataPath,
prefetchValues
)
}

const hardNavigate = shouldHardNavigate(
// TODO-APP: remove ''
Expand Down Expand Up @@ -252,10 +303,12 @@ function navigateReducer_PPR(

return data.then(
([flightData, canonicalUrlOverride, _postponed]) => {
let isFirstRead = false
// we only want to mark this once
if (!prefetchValues.lastUsedTime) {
// important: we should only mark the cache node as dirty after we unsuspend from the call above
prefetchValues.lastUsedTime = Date.now()
isFirstRead = true
}

// Handle case when navigating to page in `pages` from `app`
Expand Down Expand Up @@ -379,12 +432,33 @@ function navigateReducer_PPR(
// tree. Or in the meantime we could factor it out into a
// separate function.
const cache: CacheNode = createEmptyCacheNode()
let applied = applyFlightData(
currentCache,
cache,
flightDataPath,
prefetchValues
)
let applied = false

if (
prefetchValues.status === PrefetchCacheEntryStatus.stale &&
!isFirstRead
) {
// When we have a stale prefetch entry, we only want to re-use the loading state of the route we're navigating to, to support instant loading navigations
// this will trigger a lazy fetch for the actual page data by nulling the `rsc` and `prefetchRsc` values for page data,
// while copying over the `loading` for the segment that contains the page data.
// We only do this on subsequent reads, as otherwise there'd be no loading data to re-use.
applied = triggerLazyFetchForLeafSegments(
cache,
currentCache,
flightSegmentPath,
treePatch
)
// since we re-used the stale cache's loading state & refreshed the data,
// update the `lastUsedTime` so that it can continue to be re-used for the next 30s
prefetchValues.lastUsedTime = Date.now()
} else {
applied = applyFlightData(
currentCache,
cache,
flightDataPath,
prefetchValues
)
}

const hardNavigate = shouldHardNavigate(
// TODO-APP: remove ''
Expand Down
4 changes: 1 addition & 3 deletions test/e2e/app-dir/app-client-cache/client-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,9 +373,7 @@ createNextDescribe(
expect(newNumber).toBe(initialNumber)
})

// TODO: Rather than reusing parts of a stale prefetch cache entry to make this work,
// we should be able to copy over the existing loading from a previous cache node on navigation.
it.skip('should refetch below the fold after 30 seconds', async () => {
it('should refetch below the fold after 30 seconds', async () => {
const randomLoadingNumber = await browser
.elementByCss('[href="/1?timeout=1000"]')
.click()
Expand Down
1 change: 0 additions & 1 deletion test/ppr-tests-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
"test/e2e/app-dir/app-client-cache/client-cache.test.ts": {
"failed": [
"app dir client cache semantics prefetch={undefined} - default should re-use the full cache for only 30 seconds",
"app dir client cache semantics prefetch={undefined} - default should refetch below the fold after 30 seconds",
"app dir client cache semantics prefetch={undefined} - default should renew the 30s cache once the data is revalidated",
"app dir client cache semantics prefetch={undefined} - default should refetch the full page after 5 mins"
]
Expand Down