Skip to content

Commit

Permalink
Reland "app-router: new client-side cache semantics" (#48695)
Browse files Browse the repository at this point in the history
Reverts #48688
fix NEXT-1011
  • Loading branch information
huozhi committed Apr 22, 2023
1 parent 485955b commit 7de1a40
Show file tree
Hide file tree
Showing 27 changed files with 951 additions and 271 deletions.
5 changes: 3 additions & 2 deletions packages/next/src/client/components/app-router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
ACTION_REFRESH,
ACTION_RESTORE,
ACTION_SERVER_PATCH,
PrefetchKind,
} from './router-reducer/router-reducer-types'
import { createHrefFromUrl } from './router-reducer/create-href-from-url'
import {
Expand Down Expand Up @@ -234,7 +235,7 @@ function Router({
const routerInstance: AppRouterInstance = {
back: () => window.history.back(),
forward: () => window.history.forward(),
prefetch: async (href) => {
prefetch: async (href, options) => {
// If prefetch has already been triggered, don't trigger it again.
if (isBot(window.navigator.userAgent)) {
return
Expand All @@ -244,12 +245,12 @@ function Router({
if (isExternalURL(url)) {
return
}

// @ts-ignore startTransition exists
React.startTransition(() => {
dispatch({
type: ACTION_PREFETCH,
url,
kind: options?.kind ?? PrefetchKind.FULL,
})
})
},
Expand Down
20 changes: 8 additions & 12 deletions packages/next/src/client/components/layout-router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -277,16 +277,7 @@ function InnerLayoutRouter({
// TODO-APP: verify if this can be null based on user code
childProp.current !== null
) {
if (childNode) {
if (childNode.status === CacheStates.LAZY_INITIALIZED) {
// @ts-expect-error we're changing it's type!
childNode.status = CacheStates.READY
// @ts-expect-error
childNode.subTreeData = childProp.current
// Mutates the prop in order to clean up the memory associated with the subTreeData as it is now part of the cache.
childProp.current = null
}
} else {
if (!childNode) {
// Add the segment's subTreeData to the cache.
// This writes to the cache when there is no item in the cache yet. It never *overwrites* existing cache items which is why it's safe in concurrent mode.
childNodes.set(cacheKey, {
Expand All @@ -295,10 +286,15 @@ function InnerLayoutRouter({
subTreeData: childProp.current,
parallelRoutes: new Map(),
})
// Mutates the prop in order to clean up the memory associated with the subTreeData as it is now part of the cache.
childProp.current = null
// In the above case childNode was set on childNodes, so we have to get it from the cacheNodes again.
childNode = childNodes.get(cacheKey)
} else {
if (childNode.status === CacheStates.LAZY_INITIALIZED) {
// @ts-expect-error we're changing it's type!
childNode.status = CacheStates.READY
// @ts-expect-error
childNode.subTreeData = childProp.current
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export function applyFlightData(
existingCache: CacheNode,
cache: CacheNode,
flightDataPath: FlightDataPath,
wasPrefetched?: boolean
wasPrefetched: boolean = false
): boolean {
// The one before last item is the router state tree patch
const [treePatch, subTreeData, head] = flightDataPath.slice(-3)
Expand All @@ -33,7 +33,12 @@ export function applyFlightData(
cache.subTreeData = existingCache.subTreeData
cache.parallelRoutes = new Map(existingCache.parallelRoutes)
// Create a copy of the existing cache with the subTreeData applied.
fillCacheWithNewSubTreeData(cache, existingCache, flightDataPath)
fillCacheWithNewSubTreeData(
cache,
existingCache,
flightDataPath,
wasPrefetched
)
}

return true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from '../app-router-headers'
import { urlToUrlWithoutFlightMarker } from '../app-router'
import { callServer } from '../../app-call-server'
import { PrefetchKind } from './router-reducer-types'

/**
* Fetch the flight data for the provided url. Takes in the current router state to decide what to render server-side.
Expand All @@ -23,7 +24,7 @@ export async function fetchServerResponse(
url: URL,
flightRouterState: FlightRouterState,
nextUrl: string | null,
prefetch?: true
prefetchKind?: PrefetchKind
): Promise<[FlightData: FlightData, canonicalUrlOverride: URL | undefined]> {
const headers: {
[RSC]: '1'
Expand All @@ -36,8 +37,14 @@ export async function fetchServerResponse(
// Provide the current router state
[NEXT_ROUTER_STATE_TREE]: JSON.stringify(flightRouterState),
}
if (prefetch) {
// Enable prefetch response

/**
* Three cases:
* - `prefetchKind` is `undefined`, it means it's a normal navigation, so we want to prefetch the page data fully
* - `prefetchKind` is `full` - we want to prefetch the whole page so same as above
* - `prefetchKind` is `auto` - if the page is dynamic, prefetch the page data partially, if static prefetch the page data fully
*/
if (prefetchKind === PrefetchKind.AUTO) {
headers[NEXT_ROUTER_PREFETCH] = '1'
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { FlightSegmentPath } from '../../../server/app-render/types'
import { CacheNode, CacheStates } from '../../../shared/lib/app-router-context'
import { createRouterCacheKey } from './create-router-cache-key'
import { fetchServerResponse } from './fetch-server-response'

/**
Expand All @@ -7,19 +9,24 @@ import { fetchServerResponse } from './fetch-server-response'
export function fillCacheWithDataProperty(
newCache: CacheNode,
existingCache: CacheNode,
segments: string[],
fetchResponse: () => ReturnType<typeof fetchServerResponse>
flightSegmentPath: FlightSegmentPath,
fetchResponse: () => ReturnType<typeof fetchServerResponse>,
bailOnParallelRoutes: boolean = false
): { bailOptimistic: boolean } | undefined {
const isLastEntry = segments.length === 1
const isLastEntry = flightSegmentPath.length <= 2

const parallelRouteKey = 'children'
const [segment] = segments
const [parallelRouteKey, segment] = flightSegmentPath
const cacheKey = createRouterCacheKey(segment)

const existingChildSegmentMap =
existingCache.parallelRoutes.get(parallelRouteKey)

if (!existingChildSegmentMap) {
if (
!existingChildSegmentMap ||
(bailOnParallelRoutes && existingCache.parallelRoutes.size > 1)
) {
// Bailout because the existing cache does not have the path to the leaf node
// or the existing cache has multiple parallel routes
// Will trigger lazy fetch in layout-router because of missing segment
return { bailOptimistic: true }
}
Expand All @@ -31,8 +38,8 @@ export function fillCacheWithDataProperty(
newCache.parallelRoutes.set(parallelRouteKey, childSegmentMap)
}

const existingChildCacheNode = existingChildSegmentMap.get(segment)
let childCacheNode = childSegmentMap.get(segment)
const existingChildCacheNode = existingChildSegmentMap.get(cacheKey)
let childCacheNode = childSegmentMap.get(cacheKey)

// In case of last segment start off the fetch at this level and don't copy further down.
if (isLastEntry) {
Expand All @@ -41,7 +48,7 @@ export function fillCacheWithDataProperty(
!childCacheNode.data ||
childCacheNode === existingChildCacheNode
) {
childSegmentMap.set(segment, {
childSegmentMap.set(cacheKey, {
status: CacheStates.DATA_FETCH,
data: fetchResponse(),
subTreeData: null,
Expand All @@ -54,7 +61,7 @@ export function fillCacheWithDataProperty(
if (!childCacheNode || !existingChildCacheNode) {
// Start fetch in the place where the existing cache doesn't have the data yet.
if (!childCacheNode) {
childSegmentMap.set(segment, {
childSegmentMap.set(cacheKey, {
status: CacheStates.DATA_FETCH,
data: fetchResponse(),
subTreeData: null,
Expand All @@ -71,13 +78,13 @@ export function fillCacheWithDataProperty(
subTreeData: childCacheNode.subTreeData,
parallelRoutes: new Map(childCacheNode.parallelRoutes),
} as CacheNode
childSegmentMap.set(segment, childCacheNode)
childSegmentMap.set(cacheKey, childCacheNode)
}

return fillCacheWithDataProperty(
childCacheNode,
existingChildCacheNode,
segments.slice(1),
flightSegmentPath.slice(2),
fetchResponse
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ describe('fillCacheWithNewSubtreeData', () => {
// Mirrors the way router-reducer values are passed in.
const flightDataPath = flightData[0]

fillCacheWithNewSubTreeData(cache, existingCache, flightDataPath)
fillCacheWithNewSubTreeData(cache, existingCache, flightDataPath, false)

const expectedCache: CacheNode = {
data: null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import { createRouterCacheKey } from './create-router-cache-key'
export function fillCacheWithNewSubTreeData(
newCache: CacheNode,
existingCache: CacheNode,
flightDataPath: FlightDataPath
flightDataPath: FlightDataPath,
wasPrefetched?: boolean
): void {
const isLastEntry = flightDataPath.length <= 5
const [parallelRouteKey, segment] = flightDataPath
Expand Down Expand Up @@ -63,7 +64,8 @@ export function fillCacheWithNewSubTreeData(
childCacheNode,
existingChildCacheNode,
flightDataPath[2],
flightDataPath[4]
flightDataPath[4],
wasPrefetched
)

childSegmentMap.set(cacheKey, childCacheNode)
Expand All @@ -90,6 +92,7 @@ export function fillCacheWithNewSubTreeData(
fillCacheWithNewSubTreeData(
childCacheNode,
existingChildCacheNode,
flightDataPath.slice(2)
flightDataPath.slice(2),
wasPrefetched
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { PrefetchCacheEntry } from './router-reducer-types'

const FIVE_MINUTES = 5 * 60 * 1000
const THIRTY_SECONDS = 30 * 1000

export enum PrefetchCacheEntryStatus {
fresh = 'fresh',
reusable = 'reusable',
expired = 'expired',
stale = 'stale',
}

export function getPrefetchEntryCacheStatus({
kind,
prefetchTime,
lastUsedTime,
}: PrefetchCacheEntry): PrefetchCacheEntryStatus {
// if the cache entry was prefetched or read less than 30s ago, then we want to re-use it
if (Date.now() < (lastUsedTime ?? prefetchTime) + THIRTY_SECONDS) {
return lastUsedTime
? PrefetchCacheEntryStatus.reusable
: PrefetchCacheEntryStatus.fresh
}

// if the cache entry was prefetched less than 5 mins ago, then we want to re-use only the loading state
if (kind === 'auto') {
if (Date.now() < prefetchTime + FIVE_MINUTES) {
return PrefetchCacheEntryStatus.stale
}
}

// if the cache entry was prefetched less than 5 mins ago and was a "full" prefetch, then we want to re-use it "full
if (kind === 'full') {
if (Date.now() < prefetchTime + FIVE_MINUTES) {
return PrefetchCacheEntryStatus.reusable
}
}

return PrefetchCacheEntryStatus.expired
}
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ describe('invalidateCacheBelowFlightSegmentPath', () => {
// @ts-expect-error TODO-APP: investigate why this is not a TS error in router-reducer.
cache.subTreeData = existingCache.subTreeData
// Create a copy of the existing cache with the subTreeData applied.
fillCacheWithNewSubTreeData(cache, existingCache, flightDataPath)
fillCacheWithNewSubTreeData(cache, existingCache, flightDataPath, false)

// Invalidate the cache below the flight segment path. This should remove the 'about' node.
invalidateCacheBelowFlightSegmentPath(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ import {
ACTION_NAVIGATE,
ACTION_PREFETCH,
PrefetchAction,
PrefetchKind,
} from '../router-reducer-types'
import { navigateReducer } from './navigate-reducer'
import { prefetchReducer } from './prefetch-reducer'
Expand Down Expand Up @@ -1005,6 +1006,7 @@ describe('navigateReducer', () => {
const prefetchAction: PrefetchAction = {
type: ACTION_PREFETCH,
url,
kind: PrefetchKind.AUTO,
}

const state = createInitialRouterState({
Expand Down Expand Up @@ -1086,6 +1088,9 @@ describe('navigateReducer', () => {
'/linking/about',
{
data: record,
kind: PrefetchKind.AUTO,
lastUsedTime: null,
prefetchTime: expect.any(Number),
treeAtTimeOfPrefetch: [
'',
{
Expand Down

0 comments on commit 7de1a40

Please sign in to comment.