Skip to content

Commit

Permalink
[Segment Cache] Interception routes
Browse files Browse the repository at this point in the history
Implements prefetching support for interception routes using the
Segment Cache.

The overall flow is the same as the previous prefetch cache
implementation. If a page varies based on the Next-URL — in other
words, if it might possibly be intercepted — we include the Next-URL
as part of the cache key. However, since most pages do not vary on
the Next-URL, and this is known at build time, for most pages we can
omit the Next-URL from the cache key for all but the first request.

We do this by checking the Vary header of the first response, and if the
Next-URL is not included, we re-key the cache entry to remove the
Next-URL. All subsequent requests for the same page will match this
entry regardless of the Next-URL.

---

One difference from the previous prefetch cache implementation: when
an entry varies by Next-URL, rather than concatentating the Next-URL to
the href to create a combined cache key, we store the entries in a
tiered map structure whose keys are tuples of the href and Next-URL.
Then we compare each key part separately. This might end up being
overkill but it's nice because we don't have to worry about escaping
the values, nor do we have to store an encoded cache key separately
from its individual parts. We will likely use the same approach for
storing segment cache entries, which vary on both the segment path and
(in some cases; not yet implemented) the search params.
  • Loading branch information
acdlite committed Dec 2, 2024
1 parent f42434f commit 38b9019
Show file tree
Hide file tree
Showing 11 changed files with 347 additions and 51 deletions.
4 changes: 2 additions & 2 deletions packages/next/src/client/components/app-router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ function Router({
? // Unlike the old implementation, the Segment Cache doesn't store its
// data in the router reducer state; it writes into a global mutable
// cache. So we don't need to dispatch an action.
prefetchWithSegmentCache
(href) => prefetchWithSegmentCache(href, actionQueue.state.nextUrl)
: (href, options) => {
// Use the old prefetch implementation.
const url = createPrefetchURL(href)
Expand Down Expand Up @@ -329,7 +329,7 @@ function Router({
}

return routerInstance
}, [dispatch, navigate])
}, [actionQueue, dispatch, navigate])

useEffect(() => {
// Exists for debugging purposes. Don't use in application code.
Expand Down
10 changes: 2 additions & 8 deletions packages/next/src/client/components/segment-cache/cache-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,12 @@
type Opaque<K, T> = T & { __brand: K }

// Only functions in this module should be allowed to create CacheKeys.
export type RouteCacheKeyId = Opaque<'RouteCacheKeyId', string>
export type NormalizedHref = Opaque<'NormalizedHref', string>
type NormalizedNextUrl = Opaque<'NormalizedNextUrl', string>
export type NormalizedNextUrl = Opaque<'NormalizedNextUrl', string>

export type RouteCacheKey = Opaque<
'RouteCacheKey',
{
id: RouteCacheKeyId
href: NormalizedHref
nextUrl: NormalizedNextUrl | null
}
Expand All @@ -28,13 +26,9 @@ export function createCacheKey(
originalUrl.search = ''

const normalizedHref = originalUrl.href as NormalizedHref
const normalizedNextUrl = (
nextUrl !== null ? nextUrl : ''
) as NormalizedNextUrl
const id = `|${normalizedHref}|${normalizedNextUrl}|` as RouteCacheKeyId
const normalizedNextUrl = nextUrl as NormalizedNextUrl | null

const cacheKey = {
id,
href: normalizedHref,
nextUrl: normalizedNextUrl,
} as RouteCacheKey
Expand Down
121 changes: 96 additions & 25 deletions packages/next/src/client/components/segment-cache/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,12 @@ import {
} from './scheduler'
import { getAppBuildId } from '../../app-build-id'
import { createHrefFromUrl } from '../router-reducer/create-href-from-url'
import type { RouteCacheKey, RouteCacheKeyId } from './cache-key'
import type {
NormalizedHref,
NormalizedNextUrl,
RouteCacheKey,
} from './cache-key'
import { createTupleMap, type TupleMap, type Prefix } from './tuple-map'

// A note on async/await when working in the prefetch cache:
//
Expand All @@ -50,6 +55,9 @@ import type { RouteCacheKey, RouteCacheKeyId } from './cache-key'

type RouteCacheEntryShared = {
staleAt: number
// This is false only if we're certain the route cannot be intercepted. It's
// true in all other cases, including on initialization when we haven't yet
// received a response from the server.
couldBeIntercepted: boolean
}

Expand Down Expand Up @@ -115,27 +123,55 @@ export type SegmentCacheEntry =
| RejectedSegmentCacheEntry
| FulfilledSegmentCacheEntry

const routeCache = new Map<RouteCacheKeyId, RouteCacheEntry>()
// Route cache entries vary on multiple keys: the href and the Next-Url. Each of
// these parts needs to be included in the internal cache key. Rather than
// concatenate the keys into a single key, we use a multi-level map, where the
// first level is keyed by href, the second level is keyed by Next-Url, and so
// on (if were to add more levels).
type RouteCacheKeypath = [NormalizedHref, NormalizedNextUrl]
const routeCacheMap: TupleMap<RouteCacheKeypath, RouteCacheEntry> =
createTupleMap()

// TODO: We may eventually store segment entries in a tuple map, too, to
// account for search params.
const segmentCache = new Map<string, SegmentCacheEntry>()

export function readRouteCacheEntry(
export function readExactRouteCacheEntry(
now: number,
key: RouteCacheKey
href: NormalizedHref,
nextUrl: NormalizedNextUrl | null
): RouteCacheEntry | null {
const existingEntry = routeCache.get(key.id)
if (existingEntry !== undefined) {
const keypath: Prefix<RouteCacheKeypath> =
nextUrl === null ? [href] : [href, nextUrl]
const existingEntry = routeCacheMap.get(keypath)
if (existingEntry !== null) {
// Check if the entry is stale
if (existingEntry.staleAt > now) {
// Reuse the existing entry.
return existingEntry
} else {
// Evict the stale entry from the cache.
evictRouteCacheEntryFromCache(key)
routeCacheMap.delete(keypath)
}
}
return null
}

export function readRouteCacheEntry(
now: number,
key: RouteCacheKey
): RouteCacheEntry | null {
// First check if there's a non-intercepted entry. Most routes cannot be
// intercepted, so this is the common case.
const nonInterceptedEntry = readExactRouteCacheEntry(now, key.href, null)
if (nonInterceptedEntry !== null && !nonInterceptedEntry.couldBeIntercepted) {
// Found a match, and the route cannot be intercepted. We can reuse it.
return nonInterceptedEntry
}
// There was no match. Check again but include the Next-Url this time.
return readExactRouteCacheEntry(now, key.href, key.nextUrl)
}

export function readSegmentCacheEntry(
now: number,
path: string
Expand Down Expand Up @@ -178,9 +214,18 @@ export function requestRouteCacheEntryFromCache(
now: number,
task: PrefetchTask
): RouteCacheEntry {
const existingEntry = readRouteCacheEntry(now, task.key)
if (existingEntry !== null) {
return existingEntry
const key = task.key
// First check if there's a non-intercepted entry. Most routes cannot be
// intercepted, so this is the common case.
const nonInterceptedEntry = readExactRouteCacheEntry(now, key.href, null)
if (nonInterceptedEntry !== null && !nonInterceptedEntry.couldBeIntercepted) {
// Found a match, and the route cannot be intercepted. We can reuse it.
return nonInterceptedEntry
}
// There was no match. Check again but include the Next-Url this time.
const exactEntry = readExactRouteCacheEntry(now, key.href, key.nextUrl)
if (exactEntry !== null) {
return exactEntry
}
// Create a pending entry and spawn a request for its data.
const pendingEntry: PendingRouteCacheEntry = {
Expand All @@ -194,12 +239,16 @@ export function requestRouteCacheEntryFromCache(
// When the response is received, this value will be replaced by a new value
// based on the stale time sent from the server.
staleAt: now + 60 * 1000,
couldBeIntercepted: false,
// This is initialized to true because we don't know yet whether the route
// could be intercepted. It's only set to false once we receive a response
// from the server.
couldBeIntercepted: true,
}
const key = task.key
const requestPromise = fetchRouteOnCacheMiss(pendingEntry, task)
trackPrefetchRequestBandwidth(requestPromise)
routeCache.set(key.id, pendingEntry)
const keypath: Prefix<RouteCacheKeypath> =
key.nextUrl === null ? [key.href] : [key.href, key.nextUrl]
routeCacheMap.set(keypath, pendingEntry)
return pendingEntry
}

Expand Down Expand Up @@ -240,10 +289,6 @@ export function requestSegmentEntryFromCache(
return pendingEntry
}

function evictRouteCacheEntryFromCache(key: RouteCacheKey): void {
routeCache.delete(key.id)
}

function evictSegmentEntryFromCache(
entry: SegmentCacheEntry,
key: string
Expand All @@ -267,14 +312,15 @@ function fulfillRouteCacheEntry(
staleAt: number,
couldBeIntercepted: boolean,
canonicalUrl: string
) {
): FulfilledRouteCacheEntry {
const fulfilledEntry: FulfilledRouteCacheEntry = entry as any
fulfilledEntry.status = EntryStatus.Fulfilled
fulfilledEntry.tree = tree
fulfilledEntry.head = head
fulfilledEntry.staleAt = staleAt
fulfilledEntry.couldBeIntercepted = couldBeIntercepted
fulfilledEntry.canonicalUrl = canonicalUrl
return fulfilledEntry
}

function fulfillSegmentCacheEntry(
Expand Down Expand Up @@ -330,8 +376,9 @@ async function fetchRouteOnCacheMiss(
// pings the scheduler to unblock the corresponding prefetch task.
const key = task.key
const href = key.href
const nextUrl = key.nextUrl
try {
const response = await fetchSegmentPrefetchResponse(href, '/_tree')
const response = await fetchSegmentPrefetchResponse(href, '/_tree', nextUrl)
if (!response || !response.ok || !response.body) {
// Received an unexpected response.
rejectRouteCacheEntry(entry, Date.now() + 10 * 1000)
Expand All @@ -358,9 +405,8 @@ async function fetchRouteOnCacheMiss(

// Check whether the response varies based on the Next-Url header.
const varyHeader = response.headers.get('vary')
const couldBeIntercepted = varyHeader
? varyHeader.includes(NEXT_URL)
: false
const couldBeIntercepted =
varyHeader !== null && varyHeader.includes(NEXT_URL)

fulfillRouteCacheEntry(
entry,
Expand All @@ -370,6 +416,26 @@ async function fetchRouteOnCacheMiss(
couldBeIntercepted,
canonicalUrl
)

if (!couldBeIntercepted && nextUrl !== null) {
// This route will never be intercepted. So we can use this entry for all
// requests to this route, regardless of the Next-Url header. This works
// because when reading the cache we always check for a valid
// non-intercepted entry first.
//
// Re-key the entry. Since we're in an async task, we must first confirm
// that the entry hasn't been concurrently modified a different task.
const currentKeypath: Prefix<RouteCacheKeypath> = [href, nextUrl]
const expectedEntry = routeCacheMap.get(currentKeypath)
if (expectedEntry === entry) {
routeCacheMap.delete(currentKeypath)
const newKeypath: Prefix<RouteCacheKeypath> = [href]
routeCacheMap.set(newKeypath, entry)
} else {
// Something else modified this entry already. Since the re-keying is
// just a performance optimization, we can safely skip it.
}
}
} catch (error) {
// Either the connection itself failed, or something bad happened while
// decoding the response.
Expand Down Expand Up @@ -401,7 +467,8 @@ async function fetchSegmentEntryOnCacheMiss(
try {
const response = await fetchSegmentPrefetchResponse(
href,
accessToken === '' ? segmentPath : `${segmentPath}.${accessToken}`
accessToken === '' ? segmentPath : `${segmentPath}.${accessToken}`,
routeKey.nextUrl
)
if (!response || !response.ok || !response.body) {
// Server responded with an error. We should still cache the response, but
Expand Down Expand Up @@ -441,14 +508,18 @@ async function fetchSegmentEntryOnCacheMiss(
}

async function fetchSegmentPrefetchResponse(
href: string,
segmentPath: string
href: NormalizedHref,
segmentPath: string,
nextUrl: NormalizedNextUrl | null
): Promise<Response | null> {
const headers: RequestHeaders = {
[RSC_HEADER]: '1',
[NEXT_ROUTER_PREFETCH_HEADER]: '1',
[NEXT_ROUTER_SEGMENT_PREFETCH_HEADER]: segmentPath,
}
if (nextUrl !== null) {
headers[NEXT_URL] = nextUrl
}
const fetchPriority = 'low'
const response = await createFetch(new URL(href), headers, fetchPriority)
const contentType = response.headers.get('content-type')
Expand Down
13 changes: 2 additions & 11 deletions packages/next/src/client/components/segment-cache/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,18 +86,9 @@ export function navigate(
): AsyncNavigationResult | SuccessfulNavigationResult | NoOpNavigationResult {
const now = Date.now()

// TODO: Interception routes not yet implemented in Segment Cache. Pass a
// Next-URL to createCacheKey.
const cacheKey = createCacheKey(url.href, null)
const cacheKey = createCacheKey(url.href, nextUrl)
const route = readRouteCacheEntry(now, cacheKey)
if (
route !== null &&
route.status === EntryStatus.Fulfilled &&
// TODO: Prefetching interception routes is not support yet by the Segment
// Cache. For now, treat this as a cache miss and fallthrough to a full
// dynamic navigation.
!route.couldBeIntercepted
) {
if (route !== null && route.status === EntryStatus.Fulfilled) {
// We have a matching prefetch.
const snapshot = readRenderSnapshotFromCache(now, route.tree)
const prefetchFlightRouterState = snapshot.flightRouterState
Expand Down
7 changes: 2 additions & 5 deletions packages/next/src/client/components/segment-cache/prefetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,12 @@ import { schedulePrefetchTask } from './scheduler'
* @param href - The URL to prefetch. Typically this will come from a <Link>,
* or router.prefetch. It must be validated before we attempt to prefetch it.
*/
export function prefetch(href: string) {
export function prefetch(href: string, nextUrl: string | null) {
const url = createPrefetchURL(href)
if (url === null) {
// This href should not be prefetched.
return
}

// TODO: Interception routes not yet implemented in Segment Cache. Pass a
// Next-URL to createCacheKey.
const cacheKey = createCacheKey(url.href, null)
const cacheKey = createCacheKey(url.href, nextUrl)
schedulePrefetchTask(cacheKey)
}
Loading

0 comments on commit 38b9019

Please sign in to comment.