Skip to content

Commit

Permalink
[Segment Cache] Initial implementation (vercel#72875)
Browse files Browse the repository at this point in the history
Based on:

- vercel#72874 
- vercel#72890 
- vercel#72872 

---

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](https://github.com/acdlite/next.js/blob/initial-implementation-client-segment-cache/packages/next/src/client/components/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 stale time, but
eventually we'll use an LRU for memory management.

-
[segment-cache/scheduler.ts](https://github.com/acdlite/next.js/blob/initial-implementation-client-segment-cache/packages/next/src/client/components/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](https://github.com/acdlite/next.js/blob/initial-implementation-client-segment-cache/packages/next/src/client/components/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 authored Nov 20, 2024
1 parent 7e22452 commit 0344392
Show file tree
Hide file tree
Showing 12 changed files with 1,370 additions and 112 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 0344392

Please sign in to comment.