diff --git a/packages/next/src/client/components/app-router.tsx b/packages/next/src/client/components/app-router.tsx index 391b4f4cc92834..93c023294995c9 100644 --- a/packages/next/src/client/components/app-router.tsx +++ b/packages/next/src/client/components/app-router.tsx @@ -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) @@ -329,7 +329,7 @@ function Router({ } return routerInstance - }, [dispatch, navigate]) + }, [actionQueue, dispatch, navigate]) useEffect(() => { // Exists for debugging purposes. Don't use in application code. diff --git a/packages/next/src/client/components/segment-cache/cache-key.ts b/packages/next/src/client/components/segment-cache/cache-key.ts index 5b0d99b9279404..1c55b46f193d59 100644 --- a/packages/next/src/client/components/segment-cache/cache-key.ts +++ b/packages/next/src/client/components/segment-cache/cache-key.ts @@ -2,14 +2,12 @@ type Opaque = 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 } @@ -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 diff --git a/packages/next/src/client/components/segment-cache/cache.ts b/packages/next/src/client/components/segment-cache/cache.ts index 1fa724f04572bc..22b0002922b19b 100644 --- a/packages/next/src/client/components/segment-cache/cache.ts +++ b/packages/next/src/client/components/segment-cache/cache.ts @@ -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: // @@ -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 } @@ -115,27 +123,55 @@ export type SegmentCacheEntry = | RejectedSegmentCacheEntry | FulfilledSegmentCacheEntry -const routeCache = new Map() +// 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 = + createTupleMap() + +// TODO: We may eventually store segment entries in a tuple map, too, to +// account for search params. const segmentCache = new Map() -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 = + 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 @@ -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 = { @@ -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 = + key.nextUrl === null ? [key.href] : [key.href, key.nextUrl] + routeCacheMap.set(keypath, pendingEntry) return pendingEntry } @@ -240,10 +289,6 @@ export function requestSegmentEntryFromCache( return pendingEntry } -function evictRouteCacheEntryFromCache(key: RouteCacheKey): void { - routeCache.delete(key.id) -} - function evictSegmentEntryFromCache( entry: SegmentCacheEntry, key: string @@ -267,7 +312,7 @@ function fulfillRouteCacheEntry( staleAt: number, couldBeIntercepted: boolean, canonicalUrl: string -) { +): FulfilledRouteCacheEntry { const fulfilledEntry: FulfilledRouteCacheEntry = entry as any fulfilledEntry.status = EntryStatus.Fulfilled fulfilledEntry.tree = tree @@ -275,6 +320,7 @@ function fulfillRouteCacheEntry( fulfilledEntry.staleAt = staleAt fulfilledEntry.couldBeIntercepted = couldBeIntercepted fulfilledEntry.canonicalUrl = canonicalUrl + return fulfilledEntry } function fulfillSegmentCacheEntry( @@ -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) @@ -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, @@ -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 = [href, nextUrl] + const expectedEntry = routeCacheMap.get(currentKeypath) + if (expectedEntry === entry) { + routeCacheMap.delete(currentKeypath) + const newKeypath: Prefix = [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. @@ -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 @@ -441,14 +508,18 @@ async function fetchSegmentEntryOnCacheMiss( } async function fetchSegmentPrefetchResponse( - href: string, - segmentPath: string + href: NormalizedHref, + segmentPath: string, + nextUrl: NormalizedNextUrl | null ): Promise { 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') diff --git a/packages/next/src/client/components/segment-cache/navigation.ts b/packages/next/src/client/components/segment-cache/navigation.ts index 5be93b593e4f10..48d203a8e542c0 100644 --- a/packages/next/src/client/components/segment-cache/navigation.ts +++ b/packages/next/src/client/components/segment-cache/navigation.ts @@ -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 diff --git a/packages/next/src/client/components/segment-cache/prefetch.ts b/packages/next/src/client/components/segment-cache/prefetch.ts index 3882f9d9cb13bd..3b68677c93c8bb 100644 --- a/packages/next/src/client/components/segment-cache/prefetch.ts +++ b/packages/next/src/client/components/segment-cache/prefetch.ts @@ -7,15 +7,12 @@ import { schedulePrefetchTask } from './scheduler' * @param href - The URL to prefetch. Typically this will come from a , * 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) } diff --git a/packages/next/src/client/components/segment-cache/tuple-map.ts b/packages/next/src/client/components/segment-cache/tuple-map.ts new file mode 100644 index 00000000000000..d78ba1b1727a2c --- /dev/null +++ b/packages/next/src/client/components/segment-cache/tuple-map.ts @@ -0,0 +1,196 @@ +// Utility type. Prefix<[A, B, C, D]> matches [A], [A, B], [A, B, C] etc. +export type Prefix = T extends [infer First, ...infer Rest] + ? [] | [First] | [First, ...Prefix] + : [] + +export type TupleMap, V> = { + set(keys: Prefix, value: V): void + get(keys: Prefix): V | null + delete(keys: Prefix): void +} + +/** + * Creates a map whose keys are tuples. Tuples are compared per-element. This + * is useful when a key has multiple parts, but you don't want to concatenate + * them into a single string value. + * + * In the Segment Cache, we use this to store cache entries by both their href + * and their Next-URL. + * + * Example: + * map.set(['https://localhost', 'foo/bar/baz'], 'yay'); + * map.get(['https://localhost', 'foo/bar/baz']); // returns 'yay' + */ +export function createTupleMap, V>(): TupleMap< + Keypath, + V +> { + type MapEntryShared = { + parent: MapEntry | null + key: any + map: Map | null + } + + type EmptyMapEntry = MapEntryShared & { + value: null + hasValue: false + } + + type FullMapEntry = MapEntryShared & { + value: V + hasValue: true + } + + type MapEntry = EmptyMapEntry | FullMapEntry + + let rootEntry: MapEntry = { + parent: null, + key: null, + hasValue: false, + value: null, + map: null, + } + + // To optimize successive lookups, we cache the last accessed keypath. + // Although it's not encoded in the type, these are both null or + // both non-null. It uses object equality, so to take advantage of this + // optimization, you must pass the same array instance to each successive + // method call, and you must also not mutate the array between calls. + let lastAccessedEntry: MapEntry | null = null + let lastAccessedKeys: Prefix | null = null + + function getOrCreateEntry(keys: Prefix): MapEntry { + if (lastAccessedKeys === keys) { + return lastAccessedEntry! + } + + // Go through each level of keys until we find the entry that matches, + // or create a new one if it doesn't already exist. + let entry = rootEntry + for (let i = 0; i < keys.length; i++) { + const key = keys[i] + let map = entry.map + if (map !== null) { + const existingEntry = map.get(key) + if (existingEntry !== undefined) { + // Found a match. Keep going. + entry = existingEntry + continue + } + } else { + map = new Map() + entry.map = map + } + // No entry exists yet at this level. Create a new one. + const newEntry: MapEntry = { + parent: entry, + key, + value: null, + hasValue: false, + map: null, + } + map.set(key, newEntry) + entry = newEntry + } + + lastAccessedKeys = keys + lastAccessedEntry = entry + + return entry + } + + function getEntryIfExists(keys: Prefix): MapEntry | null { + if (lastAccessedKeys === keys) { + return lastAccessedEntry + } + + // Go through each level of keys until we find the entry that matches, or + // return null if no match exists. + let entry = rootEntry + for (let i = 0; i < keys.length; i++) { + const key = keys[i] + let map = entry.map + if (map !== null) { + const existingEntry = map.get(key) + if (existingEntry !== undefined) { + // Found a match. Keep going. + entry = existingEntry + continue + } + } + // No entry exists at this level. + return null + } + + lastAccessedKeys = keys + lastAccessedEntry = entry + + return entry + } + + function set(keys: Prefix, value: V): void { + const entry = getOrCreateEntry(keys) + entry.hasValue = true + entry.value = value + } + + function get(keys: Prefix): V | null { + const entry = getEntryIfExists(keys) + if (entry === null || !entry.hasValue) { + return null + } + return entry.value + } + + function deleteEntry(keys: Prefix): void { + const entry = getEntryIfExists(keys) + if (entry === null || !entry.hasValue) { + return + } + + // Found a match. Delete it from the cache. + const deletedEntry: EmptyMapEntry = entry as any + deletedEntry.hasValue = false + deletedEntry.value = null + + // Check if we can garbage collect the entry. + if (deletedEntry.map === null) { + // Since this entry has no value, and also no child entries, we can + // garbage collect it. Remove it from its parent, and keep garbage + // collecting the parents until we reach a non-empty entry. + + // Unlike a `set` operation, these are no longer valid because the entry + // itself is being modified, not just the value it contains. + lastAccessedEntry = null + lastAccessedKeys = null + + let parent = deletedEntry.parent + let key = deletedEntry.key + while (parent !== null) { + const parentMap = parent.map + if (parentMap !== null) { + parentMap.delete(key) + if (parentMap.size === 0) { + // We just removed the last entry in the parent map. + parent.map = null + if (parent.value === null) { + // The parent node has no child entries, nor does it have a value + // on itself. It can be garbage collected. Keep going. + key = parent.key + parent = parent.parent + continue + } + } + } + // The parent is not empty. Stop garbage collecting. + break + } + } + } + + return { + set, + get, + delete: deleteEntry, + } +} diff --git a/test/e2e/app-dir/segment-cache/basic/app/interception/feed/(..)photo/page.tsx b/test/e2e/app-dir/segment-cache/basic/app/interception/feed/(..)photo/page.tsx new file mode 100644 index 00000000000000..a8c156d54b7b48 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/basic/app/interception/feed/(..)photo/page.tsx @@ -0,0 +1,3 @@ +export default function InterceptedPhotoPage() { + return
Intercepted photo page
+} diff --git a/test/e2e/app-dir/segment-cache/basic/app/interception/feed/layout.tsx b/test/e2e/app-dir/segment-cache/basic/app/interception/feed/layout.tsx new file mode 100644 index 00000000000000..e10750e17cf86a --- /dev/null +++ b/test/e2e/app-dir/segment-cache/basic/app/interception/feed/layout.tsx @@ -0,0 +1,8 @@ +export default function FeedLayout({ children }) { + return ( +
+ Feed layout +
{children}
+
+ ) +} diff --git a/test/e2e/app-dir/segment-cache/basic/app/interception/feed/page.tsx b/test/e2e/app-dir/segment-cache/basic/app/interception/feed/page.tsx new file mode 100644 index 00000000000000..27a670b68b7682 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/basic/app/interception/feed/page.tsx @@ -0,0 +1,5 @@ +import Link from 'next/link' + +export default function FeedPage() { + return Go to photo +} diff --git a/test/e2e/app-dir/segment-cache/basic/app/interception/photo/page.tsx b/test/e2e/app-dir/segment-cache/basic/app/interception/photo/page.tsx new file mode 100644 index 00000000000000..62efb879aaae92 --- /dev/null +++ b/test/e2e/app-dir/segment-cache/basic/app/interception/photo/page.tsx @@ -0,0 +1,3 @@ +export default function PhotoPage() { + return 'Photo page (normal, not intercepted)' +} diff --git a/test/e2e/app-dir/segment-cache/basic/segment-cache-basic.test.ts b/test/e2e/app-dir/segment-cache/basic/segment-cache-basic.test.ts index 3514c5ae2be8d5..d44d1e2f424e67 100644 --- a/test/e2e/app-dir/segment-cache/basic/segment-cache-basic.test.ts +++ b/test/e2e/app-dir/segment-cache/basic/segment-cache-basic.test.ts @@ -76,6 +76,34 @@ describe('segment cache (basic tests)', () => { `"
Static in nav
Dynamic in nav
"` ) }) + + it('prefetch interception route', async () => { + const interceptor = createRequestInterceptor() + const browser = await next.browser('/interception/feed', { + beforePageLoad(page: Page) { + page.route('**/*', async (route: Route) => { + await interceptor.interceptRoute(route) + }) + }, + }) + + // Rendering the link triggers a prefetch of the test page. + const link = await browser.elementByCss('a') + + // Before navigating to the test page, block all navigation requests from + // resolving so we can simulate what happens on a slow connection when + // the cache has been populated with prefetched data. + const navigationsLock = interceptor.lockNavigations() + + // Navigate to the test page + await link.click() + + // The page should render immediately because it was prefetched + const div = await browser.elementById('intercepted-photo-page') + expect(await div.innerHTML()).toBe('Intercepted photo page') + + navigationsLock.release() + }) }) function createRequestInterceptor() {