Skip to content

Commit

Permalink
seed prefetch cache with initial page (#61535)
Browse files Browse the repository at this point in the history
### What
When navigating back to a page that you had already loaded, it currently
results in a prefetch cache miss and will re-trigger any data
fetching/loading despite it being available.

### Why
When creating the initial router state, the prefetch cache is
initialized to an empty map.

### How
This uses the `initialTree` passed from the server to seed the cache for
that route with flight data.

Closes NEXT-2001
  • Loading branch information
ztanner authored Feb 13, 2024
1 parent b9861fd commit 4e03b85
Show file tree
Hide file tree
Showing 12 changed files with 486 additions and 103 deletions.
12 changes: 10 additions & 2 deletions packages/next/src/client/components/app-router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@ function Router({
initialTree,
initialCanonicalUrl,
initialSeedData,
couldBeIntercepted,
assetPrefix,
missingSlots,
}: AppRouterProps) {
Expand All @@ -275,11 +276,18 @@ function Router({
initialCanonicalUrl,
initialTree,
initialParallelRoutes,
isServer,
location: !isServer ? window.location : null,
initialHead,
couldBeIntercepted,
}),
[buildId, initialSeedData, initialCanonicalUrl, initialTree, initialHead]
[
buildId,
initialSeedData,
initialCanonicalUrl,
initialTree,
initialHead,
couldBeIntercepted,
]
)
const [reducerState, dispatch, sync] =
useReducerWithReduxDevtools(initialState)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React from 'react'
import type { FlightRouterState } from '../../../server/app-render/types'
import type { CacheNode } from '../../../shared/lib/app-router-context.shared-runtime'
import { createInitialRouterState } from './create-initial-router-state'
import { PrefetchCacheEntryStatus, PrefetchKind } from './router-reducer-types'

const buildId = 'development'

Expand Down Expand Up @@ -38,9 +39,9 @@ describe('createInitialRouterState', () => {
initialCanonicalUrl,
initialSeedData: ['', {}, children],
initialParallelRoutes,
isServer: false,
location: new URL('/linking', 'https://localhost') as any,
initialHead: <title>Test</title>,
couldBeIntercepted: false,
})

const state2 = createInitialRouterState({
Expand All @@ -49,7 +50,6 @@ describe('createInitialRouterState', () => {
initialCanonicalUrl,
initialSeedData: ['', {}, children],
initialParallelRoutes,
isServer: false,
location: new URL('/linking', 'https://localhost') as any,
initialHead: <title>Test</title>,
})
Expand Down Expand Up @@ -96,7 +96,20 @@ describe('createInitialRouterState', () => {
buildId,
tree: initialTree,
canonicalUrl: initialCanonicalUrl,
prefetchCache: new Map(),
prefetchCache: new Map([
[
'/linking',
{
key: '/linking',
data: expect.any(Promise),
prefetchTime: expect.any(Number),
kind: PrefetchKind.AUTO,
lastUsedTime: null,
treeAtTimeOfPrefetch: initialTree,
status: PrefetchCacheEntryStatus.fresh,
},
],
]),
pushRef: {
pendingPush: false,
mpaNavigation: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,24 @@ import type { CacheNode } from '../../../shared/lib/app-router-context.shared-ru
import type {
FlightRouterState,
CacheNodeSeedData,
FlightData,
} from '../../../server/app-render/types'

import { createHrefFromUrl } from './create-href-from-url'
import { fillLazyItemsTillLeafWithHead } from './fill-lazy-items-till-leaf-with-head'
import { extractPathFromFlightRouterState } from './compute-changed-path'
import { createPrefetchCacheEntryForInitialLoad } from './prefetch-cache-utils'
import { PrefetchKind, type PrefetchCacheEntry } from './router-reducer-types'

export interface InitialRouterStateParameters {
buildId: string
initialTree: FlightRouterState
initialCanonicalUrl: string
initialSeedData: CacheNodeSeedData
initialParallelRoutes: CacheNode['parallelRoutes']
isServer: boolean
location: Location | null
initialHead: ReactNode
couldBeIntercepted?: boolean
}

export function createInitialRouterState({
Expand All @@ -26,10 +29,11 @@ export function createInitialRouterState({
initialSeedData,
initialCanonicalUrl,
initialParallelRoutes,
isServer,
location,
initialHead,
couldBeIntercepted,
}: InitialRouterStateParameters) {
const isServer = !location
const rsc = initialSeedData[2]

const cache: CacheNode = {
Expand All @@ -40,6 +44,8 @@ export function createInitialRouterState({
parallelRoutes: isServer ? new Map() : initialParallelRoutes,
}

const prefetchCache = new Map<string, PrefetchCacheEntry>()

// When the cache hasn't been seeded yet we fill the cache with the head.
if (initialParallelRoutes === null || initialParallelRoutes.size === 0) {
fillLazyItemsTillLeafWithHead(
Expand All @@ -51,11 +57,11 @@ export function createInitialRouterState({
)
}

return {
const initialState = {
buildId,
tree: initialTree,
cache,
prefetchCache: new Map(),
prefetchCache,
pushRef: {
pendingPush: false,
mpaNavigation: false,
Expand All @@ -81,4 +87,23 @@ export function createInitialRouterState({
(extractPathFromFlightRouterState(initialTree) || location?.pathname) ??
null,
}

if (location) {
// Seed the prefetch cache with this page's data.
// This is to prevent needlessly re-prefetching a page that is already reusable,
// and will avoid triggering a loading state/data fetch stall when navigating back to the page.
const url = new URL(location.pathname, location.origin)

const initialFlightData: FlightData = [['', initialTree, null, null]]
createPrefetchCacheEntryForInitialLoad({
url,
kind: PrefetchKind.AUTO,
data: [initialFlightData, undefined, false, couldBeIntercepted],
tree: initialState.tree,
prefetchCache: initialState.prefetchCache,
nextUrl: initialState.nextUrl,
})
}

return initialState
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { createHrefFromUrl } from './create-href-from-url'
import { fetchServerResponse } from './fetch-server-response'
import {
fetchServerResponse,
type FetchServerResponseResult,
} from './fetch-server-response'
import {
PrefetchCacheEntryStatus,
type PrefetchCacheEntry,
Expand Down Expand Up @@ -113,6 +116,10 @@ export function getOrCreatePrefetchCacheEntry({
})
}

/*
* Used to take an existing cache entry and prefix it with the nextUrl, if it exists.
* This ensures that we don't have conflicting cache entries for the same URL (as is the case with route interception).
*/
function prefixExistingPrefetchCacheEntry({
url,
nextUrl,
Expand All @@ -133,7 +140,43 @@ function prefixExistingPrefetchCacheEntry({
}

/**
* Creates a prefetch entry for data that has not been resolved. This will add the prefetch request to a promise queue.
* Use to seed the prefetch cache with data that has already been fetched.
*/
export function createPrefetchCacheEntryForInitialLoad({
nextUrl,
tree,
prefetchCache,
url,
kind,
data,
}: Pick<ReadonlyReducerState, 'nextUrl' | 'tree' | 'prefetchCache'> & {
url: URL
kind: PrefetchKind
data: FetchServerResponseResult
}) {
const [, , , intercept] = data
// if the prefetch corresponds with an interception route, we use the nextUrl to prefix the cache key
const prefetchCacheKey = intercept
? createPrefetchCacheKey(url, nextUrl)
: createPrefetchCacheKey(url)

const prefetchEntry = {
treeAtTimeOfPrefetch: tree,
data: Promise.resolve(data),
kind,
prefetchTime: Date.now(),
lastUsedTime: null,
key: prefetchCacheKey,
status: PrefetchCacheEntryStatus.fresh,
}

prefetchCache.set(prefetchCacheKey, prefetchEntry)

return prefetchEntry
}

/**
* Creates a prefetch entry entry and enqueues a fetch request to retrieve the data.
*/
function createLazyPrefetchEntry({
url,
Expand Down
Loading

0 comments on commit 4e03b85

Please sign in to comment.