Skip to content

Commit

Permalink
Initial implementation of client Segment Cache
Browse files Browse the repository at this point in the history
This adds an initial implementation of the client Segment Cache, behind
the experimental `clientSegmentCache` flag. (Note: It is not anywhere
close to being ready for production use. It will take a while for it
to reach parity with the existing implementation.)

I've discussed the motivation in previous PRs, but I'll share a brief
summary here again:

The client Segment Cache is a rewrite of App Router's client caching
implementation, designed with PPR and "use cache" in mind. Its main
distinguishing feature from the current implementation is that it
fetches/caches/expires data per route segment, rather than per full URL.
An example of what this means in practical terms is that shared layouts
are deduplicated in the cache, resulting in less bandwidth. There are
other benefits we have in mind but that's the starting point.

I've tried to extract the work here into reasonably-sized commits (many
of which have already landed) but this one here is sorta unavoidably
large. Here are the main pieces:

- segment-cache/cache.ts: This module is where the cache entries are
  maintained in memory. An important design principle is that you must
  be able to read from the cache synchronously without awaiting any
  promises. We avoid the use of async/await wherever possible; instead,
  async tasks write their results directly into the cache. This also
  helps to avoid race conditions.

  Currently there's no eviction policy other than
  staleTime, but eventually we'll use an LRU for memory management.

- segment-cache/scheduler.ts: This module is primarily a task scheduler.
  It's also used to manage network bandwidth. The design is inspired by
  React Suspense and Rust Futures — tasks are pull-based, not
  push-based. The backing data structure is a MinHeap/PriorityQueue, to
  support efficient reprioritization of tasks.

- segment-cache/navigation.ts: This module is responsible for creating
  a snapshot of the cache at the time of a navigation. Right now it's
  mostly a bunch of glue code to interop with the data structures used
  by the rest of the App Router, like CacheNodeSeedData
  and FlightRouterState. The long term plan is to move everything to
  using the Segment Cache and refactoring those data structures.

Additional explanations are provided inline.
  • Loading branch information
acdlite committed Nov 20, 2024
1 parent c00629b commit f6ec887
Show file tree
Hide file tree
Showing 12 changed files with 1,345 additions and 102 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export type RequestHeaders = {
'Next-Test-Fetch-Priority'?: RequestInit['priority']
}

function urlToUrlWithoutFlightMarker(url: string): URL {
export function urlToUrlWithoutFlightMarker(url: string): URL {
const urlWithoutFlightParameters = new URL(url, location.origin)
urlWithoutFlightParameters.searchParams.delete(NEXT_RSC_UNION_QUERY)
if (process.env.NODE_ENV === 'production') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { handleAliasedPrefetchEntry } from '../aliased-prefetch-navigations'
import {
navigate as navigateUsingSegmentCache,
NavigationResultTag,
type NavigationResult,
} from '../../segment-cache/navigation'

export function handleExternalUrl(
Expand Down Expand Up @@ -102,6 +103,50 @@ function triggerLazyFetchForLeafSegments(
return appliedPatch
}

function handleNavigationResult(
state: ReadonlyReducerState,
mutable: Mutable,
pendingPush: boolean,
result: NavigationResult
): ReducerState {
switch (result.tag) {
case NavigationResultTag.MPA: {
// Perform an MPA navigation.
const newUrl = result.data
return handleExternalUrl(state, mutable, newUrl, pendingPush)
}
case NavigationResultTag.NoOp:
// The server responded with no change to the current page.
return handleMutable(state, mutable)
case NavigationResultTag.Success: {
// Received a new result.
mutable.cache = result.data.cacheNode
mutable.patchedTree = result.data.flightRouterState
mutable.canonicalUrl = result.data.canonicalUrl
// TODO: Not yet implemented
// mutable.scrollableSegments = scrollableSegments
// mutable.hashFragment = hash
// mutable.shouldScroll = shouldScroll
return handleMutable(state, mutable)
}
case NavigationResultTag.Async: {
return result.data.then(
(asyncResult) =>
handleNavigationResult(state, mutable, pendingPush, asyncResult),
// If the navigation failed, return the current state.
// TODO: This matches the current behavior but we need to do something
// better here if the network fails.
() => {
return state
}
)
}
default:
const _exhaustiveCheck: never = result
return state
}
}

export function navigateReducer(
state: ReadonlyReducerState,
action: NavigateAction
Expand Down Expand Up @@ -140,47 +185,13 @@ export function navigateReducer(
// TODO: Currently this always returns an async result, but in the future
// it will return a sync result if the navigation was prefetched. Hence
// a result type that's more complicated than you might expect.
const asyncResult = navigateUsingSegmentCache(
const result = navigateUsingSegmentCache(
url,
state.cache,
state.tree,
state.nextUrl
)
return asyncResult.data.then(
(result) => {
switch (result.tag) {
case NavigationResultTag.MPA: {
// Perform an MPA navigation.
const newUrl = result.data
return handleExternalUrl(state, mutable, newUrl, pendingPush)
}
case NavigationResultTag.NoOp:
// The server responded with no change to the current page.
return handleMutable(state, mutable)
case NavigationResultTag.Success: {
// Received a new result.
mutable.cache = result.data.cacheNode
mutable.patchedTree = result.data.flightRouterState
mutable.canonicalUrl = result.data.canonicalUrl

// TODO: Not yet implemented
// mutable.scrollableSegments = scrollableSegments
// mutable.hashFragment = hash
// mutable.shouldScroll = shouldScroll
return handleMutable(state, mutable)
}
default:
const _exhaustiveCheck: never = result
return state
}
},
// If the navigation failed, return the current state.
// TODO: This matches the current behavior but we need to do something
// better here if the network fails.
() => {
return state
}
)
return handleNavigationResult(state, mutable, pendingPush, result)
}

const prefetchValues = getOrCreatePrefetchCacheEntry({
Expand Down
43 changes: 43 additions & 0 deletions packages/next/src/client/components/segment-cache/cache-key.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// TypeScript trick to simulate opaque types, like in Flow.
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 RouteCacheKey = Opaque<
'RouteCacheKey',
{
id: RouteCacheKeyId
href: NormalizedHref
nextUrl: NormalizedNextUrl | null
}
>

export function createCacheKey(
originalHref: string,
nextUrl: string | null
): RouteCacheKey {
const originalUrl = new URL(originalHref)

// TODO: As of now, we never include search params in the cache key because
// per-segment prefetch requests are always static, and cannot contain search
// params. But to support <Link prefetch={true}>, we will sometimes populate
// the cache with dynamic data, so this will have to change.
originalUrl.search = ''

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

const cacheKey = {
id,
href: normalizedHref,
nextUrl: normalizedNextUrl,
} as RouteCacheKey

return cacheKey
}
Loading

0 comments on commit f6ec887

Please sign in to comment.