Skip to content

Commit

Permalink
[Segment Cache] Evict client cache on revalidate
Browse files Browse the repository at this point in the history
This implements evicting the client cache when a Server Action calls
revalidatePath or revalidateTag.

Similar to the old prefetching implementation, it works by clearing the
entire client cache, as opposed to only the affected path or tags. There
are more changes needed on the server before we can support granular
cache eviction. This just gets us to parity with the status quo.
  • Loading branch information
acdlite committed Jan 14, 2025
1 parent 39c7ade commit 9e58505
Show file tree
Hide file tree
Showing 10 changed files with 387 additions and 40 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { createEmptyCacheNode } from '../../app-router'
import { handleSegmentMismatch } from '../handle-segment-mismatch'
import { hasInterceptionRouteInCurrentTree } from './has-interception-route-in-current-tree'
import { refreshInactiveParallelSegments } from '../refetch-inactive-parallel-segments'
import { revalidateEntireCache } from '../../segment-cache/cache'

export function refreshReducer(
state: ReadonlyReducerState,
Expand Down Expand Up @@ -121,7 +122,11 @@ export function refreshReducer(
head,
undefined
)
mutable.prefetchCache = new Map()
if (process.env.__NEXT_CLIENT_SEGMENT_CACHE) {
revalidateEntireCache()
} else {
mutable.prefetchCache = new Map()
}
}

await refreshInactiveParallelSegments({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import {
extractInfoFromServerReferenceId,
omitUnusedArgs,
} from './server-reference-info'
import { revalidateEntireCache } from '../../segment-cache/cache'

type FetchServerActionResult = {
redirectLocation: URL | undefined
Expand Down Expand Up @@ -338,8 +339,11 @@ export function serverActionReducer(
)

mutable.cache = cache
mutable.prefetchCache = new Map()

if (process.env.__NEXT_CLIENT_SEGMENT_CACHE) {
revalidateEntireCache()
} else {
mutable.prefetchCache = new Map()
}
if (actionRevalidated) {
await refreshInactiveParallelSegments({
state,
Expand Down
29 changes: 25 additions & 4 deletions packages/next/src/client/components/segment-cache/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ export type NonEmptySegmentCacheEntry = Exclude<
// 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> =
let routeCacheMap: TupleMap<RouteCacheKeypath, RouteCacheEntry> =
createTupleMap()

// We use an LRU for memory management. We must update this whenever we add or
Expand All @@ -221,24 +221,42 @@ const routeCacheMap: TupleMap<RouteCacheKeypath, RouteCacheEntry> =
// on navigator.deviceMemory, or some other heuristic. We should make this
// customizable via the Next.js config, too.
const maxRouteLruSize = 10 * 1024 * 1024 // 10 MB
const routeCacheLru = createLRU<RouteCacheEntry>(
let routeCacheLru = createLRU<RouteCacheEntry>(
maxRouteLruSize,
onRouteLRUEviction
)

// TODO: We may eventually store segment entries in a tuple map, too, to
// account for search params.
const segmentCacheMap = new Map<string, SegmentCacheEntry>()
let segmentCacheMap = new Map<string, SegmentCacheEntry>()
// NOTE: Segments and Route entries are managed by separate LRUs. We could
// combine them into a single LRU, but because they are separate types, we'd
// need to wrap each one in an extra LRU node (to maintain monomorphism, at the
// cost of additional memory).
const maxSegmentLruSize = 50 * 1024 * 1024 // 50 MB
const segmentCacheLru = createLRU<SegmentCacheEntry>(
let segmentCacheLru = createLRU<SegmentCacheEntry>(
maxSegmentLruSize,
onSegmentLRUEviction
)

/**
* Used to clear the client prefetch cache when a server action calls
* revalidatePath or revalidateTag. Eventually we will support only clearing the
* segments that were actually affected, but there's more work to be done on the
* server before the client is able to do this correctly.
*/
export function revalidateEntireCache() {
// Clearing the cache also effectively rejects any pending requests, because
// when the response is received, it gets written into a cache entry that is
// no longer reachable.
// TODO: There's an exception to this case that we don't currently handle
// correctly: background revalidations. See note in `upsertSegmentEntry`.
routeCacheMap = createTupleMap()
routeCacheLru = createLRU(maxRouteLruSize, onRouteLRUEviction)
segmentCacheMap = new Map()
segmentCacheLru = createLRU(maxSegmentLruSize, onSegmentLRUEviction)
}

export function readExactRouteCacheEntry(
now: number,
href: NormalizedHref,
Expand Down Expand Up @@ -449,6 +467,9 @@ export function upsertSegmentEntry(
// We have a new entry that has not yet been inserted into the cache. Before
// we do so, we need to confirm whether it takes precedence over the existing
// entry (if one exists).
// TODO: We should not upsert an entry if its key was invalidated in the time
// since the request was made. We can do that by passing the "owner" entry to
// this function and confirming it's the same as `exisingEntry`.
const existingEntry = readSegmentCacheEntry(now, segmentKeyPath)
if (existingEntry !== null) {
if (candidateEntry.isPartial && !existingEntry.isPartial) {
Expand Down
40 changes: 40 additions & 0 deletions test/e2e/app-dir/segment-cache/revalidation/app/greeting/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
'use cache'

import { unstable_cacheTag as cacheTag } from 'next/cache'
import { Suspense } from 'react'

const TEST_DATA_SERVICE_URL = process.env.TEST_DATA_SERVICE_URL
const ARTIFICIAL_DELAY = 3000

async function Greeting() {
cacheTag('random-greeting')
if (!TEST_DATA_SERVICE_URL) {
// If environment variable is not set, resolve automatically after a delay.
// This is so you can run the test app locally without spinning up a
// data server.
await new Promise<void>((resolve) =>
setTimeout(() => resolve(), ARTIFICIAL_DELAY)
)
// Return a random greeting
return ['Hello', 'Hi', 'Hey', 'Howdy'][Math.floor(Math.random() * 4)]
}
const response = await fetch(TEST_DATA_SERVICE_URL + '?key=random-greeting')
const text = await response.text()
if (response.status !== 200) {
throw new Error(text)
}
return (
<>
<h1>Greeting</h1>
<div id="greeting">{text}</div>
</>
)
}

export default async function Page() {
return (
<Suspense fallback="Loading...">
<Greeting />
</Suspense>
)
}
11 changes: 11 additions & 0 deletions test/e2e/app-dir/segment-cache/revalidation/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
44 changes: 44 additions & 0 deletions test/e2e/app-dir/segment-cache/revalidation/app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
'use cache'

import { revalidatePath, revalidateTag } from 'next/cache'
import { LinkAccordion } from '../components/link-accordion'
import Link from 'next/link'

export default async function Page() {
return (
<>
<form>
<button
id="revalidate-by-path"
formAction={async function () {
'use server'
revalidatePath('/greeting')
}}
>
Revalidate by path
</button>
<button
id="revalidate-by-tag"
formAction={async function () {
'use server'
revalidateTag('random-greeting')
}}
>
Revalidate by tag
</button>
</form>
<ul>
<li>
<LinkAccordion href="/greeting">
Link to target page with prefetching enabled
</LinkAccordion>
</li>
<li>
<Link prefetch={false} href="/greeting">
Link to target with prefetching disabled
</Link>
</li>
</ul>
</>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
'use client'

import Link from 'next/link'
import { useState } from 'react'

export function LinkAccordion({ href, children }) {
const [isVisible, setIsVisible] = useState(false)
return (
<>
<input
type="checkbox"
checked={isVisible}
onChange={() => setIsVisible(!isVisible)}
data-link-accordion={href}
/>
{isVisible ? (
<Link href={href}>{children}</Link>
) : (
`${children} (link is hidden)`
)}
</>
)
}
12 changes: 12 additions & 0 deletions test/e2e/app-dir/segment-cache/revalidation/next.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {
experimental: {
ppr: true,
dynamicIO: true,
clientSegmentCache: true,
},
}

module.exports = nextConfig
Loading

0 comments on commit 9e58505

Please sign in to comment.