Skip to content

Commit 6d537d3

Browse files
authored
Lazily call refreshTags and getExpiration (#77779)
When `"use cache"` is not used on the current route, we don't need to call `refreshTags` for the configured cache handlers. So instead of calling it at the beginning of the request for every cache handler, we now call it lazily right before the first cache entry is retrieved for the respective cache handler (once per request). Similarly, we now call `getExpiration` for the implicit tags of the current route lazily (also once per request) after an existing cache entry has been retrieved, and its timestamp needs to be compared with the expiration of the implicit tags.
1 parent 1adef15 commit 6d537d3

File tree

9 files changed

+140
-54
lines changed

9 files changed

+140
-54
lines changed

Diff for: packages/next/src/server/app-render/work-async-storage.external.ts

+16-5
Original file line numberDiff line numberDiff line change
@@ -54,15 +54,26 @@ export interface WorkStore {
5454
nextFetchId?: number
5555
pathWasRevalidated?: boolean
5656

57-
// Tags that were revalidated during the current request. They need to be sent
58-
// to cache handlers to propagate their revalidation.
57+
/**
58+
* Tags that were revalidated during the current request. They need to be sent
59+
* to cache handlers to propagate their revalidation.
60+
*/
5961
pendingRevalidatedTags?: string[]
6062

61-
// Tags that were previously revalidated (e.g. by a redirecting server action)
62-
// and have already been sent to cache handlers. Retrieved cache entries that
63-
// include any of these tags must be discarded.
63+
/**
64+
* Tags that were previously revalidated (e.g. by a redirecting server action)
65+
* and have already been sent to cache handlers. Retrieved cache entries that
66+
* include any of these tags must be discarded.
67+
*/
6468
readonly previouslyRevalidatedTags: readonly string[]
6569

70+
/**
71+
* This map contains promise-like values so that we can evaluate them lazily
72+
* when a cache entry is read. It allows us to skip refreshing tags if no
73+
* caches are read at all.
74+
*/
75+
readonly refreshTagsByCacheKind: Map<string, PromiseLike<void>>
76+
6677
fetchMetrics?: FetchMetrics
6778

6879
isDraftMode?: boolean

Diff for: packages/next/src/server/async-storage/work-store.ts

+25
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import type { CacheLife } from '../use-cache/cache-life'
1010
import { AfterContext } from '../after/after-context'
1111

1212
import { normalizeAppPath } from '../../shared/lib/router/utils/app-paths'
13+
import { createLazyResult } from '../lib/lazy-result'
14+
import { getCacheHandlerEntries } from '../use-cache/handlers'
1315

1416
export type WorkStoreContext = {
1517
/**
@@ -135,6 +137,7 @@ export function createWorkStore({
135137
dynamicIOEnabled: renderOpts.experimental.dynamicIO,
136138
dev: renderOpts.dev ?? false,
137139
previouslyRevalidatedTags,
140+
refreshTagsByCacheKind: createRefreshTagsByCacheKind(),
138141
}
139142

140143
// TODO: remove this when we resolve accessing the store outside the execution context
@@ -151,3 +154,25 @@ function createAfterContext(renderOpts: RequestLifecycleOpts): AfterContext {
151154
onTaskError: onAfterTaskError,
152155
})
153156
}
157+
158+
/**
159+
* Creates a map with promise-like objects, that refresh tags for the given
160+
* cache kind when they're awaited for the first time.
161+
*/
162+
function createRefreshTagsByCacheKind(): Map<string, PromiseLike<void>> {
163+
const refreshTagsByCacheKind = new Map<string, PromiseLike<void>>()
164+
const cacheHandlers = getCacheHandlerEntries()
165+
166+
if (cacheHandlers) {
167+
for (const [kind, cacheHandler] of cacheHandlers) {
168+
if ('refreshTags' in cacheHandler) {
169+
refreshTagsByCacheKind.set(
170+
kind,
171+
createLazyResult(async () => cacheHandler.refreshTags())
172+
)
173+
}
174+
}
175+
}
176+
177+
return refreshTagsByCacheKind
178+
}

Diff for: packages/next/src/server/base-server.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -1440,7 +1440,9 @@ export default abstract class Server<
14401440
await Promise.all(
14411441
[...cacheHandlers].map(async (cacheHandler) => {
14421442
if ('refreshTags' in cacheHandler) {
1443-
await cacheHandler.refreshTags()
1443+
// Note: cacheHandler.refreshTags() is called lazily before the
1444+
// first cache entry is retrieved. It allows us to skip the
1445+
// refresh request if no caches are read at all.
14441446
} else {
14451447
const previouslyRevalidatedTags = getPreviouslyRevalidatedTags(
14461448
req.headers,

Diff for: packages/next/src/server/lib/implicit-tags.ts

+10-5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { NEXT_CACHE_IMPLICIT_TAG_ID } from '../../lib/constants'
22
import type { FallbackRouteParams } from '../request/fallback-params'
33
import { getCacheHandlers } from '../use-cache/handlers'
4+
import { createLazyResult } from './lazy-result'
45

56
export interface ImplicitTags {
67
/**
@@ -9,11 +10,13 @@ export interface ImplicitTags {
910
*/
1011
readonly tags: string[]
1112
/**
12-
* Modern cache handlers don't receive implicit tags. Instead, the
13-
* implicit tags' expiration is stored in the work unit store, and used to
14-
* compare with a cache entry's timestamp.
13+
* Modern cache handlers don't receive implicit tags. Instead, the implicit
14+
* tags' expiration is stored in the work unit store, and used to compare with
15+
* a cache entry's timestamp. Note: This is a promise-like value so that we
16+
* can evaluate it lazily when a cache entry is read. It allows us to skip
17+
* fetching the expiration value if no caches are read at all.
1518
*/
16-
readonly expiration: number
19+
readonly expiration: PromiseLike<number>
1720
}
1821

1922
const getDerivedTags = (pathname: string): string[] => {
@@ -98,7 +101,9 @@ export async function getImplicitTags(
98101
tags.push(tag)
99102
}
100103

101-
const expiration = await getImplicitTagsExpiration(tags)
104+
const expiration = createLazyResult(async () =>
105+
getImplicitTagsExpiration(tags)
106+
)
102107

103108
return { tags, expiration }
104109
}

Diff for: packages/next/src/server/lib/lazy-result.ts

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* Calls the given async function only when the returned promise-like object is
3+
* awaited.
4+
*/
5+
export function createLazyResult<TResult>(
6+
fn: () => Promise<TResult>
7+
): PromiseLike<TResult> {
8+
let pendingResult: Promise<TResult> | undefined
9+
10+
return {
11+
then(onfulfilled, onrejected) {
12+
if (!pendingResult) {
13+
pendingResult = fn()
14+
}
15+
16+
return pendingResult.then(onfulfilled, onrejected)
17+
},
18+
}
19+
}

Diff for: packages/next/src/server/use-cache/handlers.ts

+21-3
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,8 @@ export function initializeCacheHandlers(): boolean {
7878
/**
7979
* Get a cache handler by kind.
8080
* @param kind - The kind of cache handler to get.
81-
* @returns The cache handler, or `undefined` if it is not initialized or does not exist.
81+
* @returns The cache handler, or `undefined` if it does not exist.
82+
* @throws If the cache handlers are not initialized.
8283
*/
8384
export function getCacheHandler(kind: string): CacheHandlerCompat | undefined {
8485
// This should never be called before initializeCacheHandlers.
@@ -90,8 +91,9 @@ export function getCacheHandler(kind: string): CacheHandlerCompat | undefined {
9091
}
9192

9293
/**
93-
* Get an iterator over the cache handlers.
94-
* @returns An iterator over the cache handlers, or `undefined` if they are not initialized.
94+
* Get a set iterator over the cache handlers.
95+
* @returns An iterator over the cache handlers, or `undefined` if they are not
96+
* initialized.
9597
*/
9698
export function getCacheHandlers():
9799
| SetIterator<CacheHandlerCompat>
@@ -103,6 +105,22 @@ export function getCacheHandlers():
103105
return reference[handlersSetSymbol].values()
104106
}
105107

108+
/**
109+
* Get a map iterator over the cache handlers (keyed by kind).
110+
* @returns An iterator over the cache handler entries, or `undefined` if they
111+
* are not initialized.
112+
* @throws If the cache handlers are not initialized.
113+
*/
114+
export function getCacheHandlerEntries():
115+
| MapIterator<[string, CacheHandlerCompat]>
116+
| undefined {
117+
if (!reference[handlersMapSymbol]) {
118+
return undefined
119+
}
120+
121+
return reference[handlersMapSymbol].entries()
122+
}
123+
106124
/**
107125
* Set a cache handler by kind.
108126
* @param kind - The kind of cache handler to set.

Diff for: packages/next/src/server/use-cache/use-cache-wrapper.ts

+13-23
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import {
5252
import type { Params } from '../request/params'
5353
import React from 'react'
5454
import type { ImplicitTags } from '../lib/implicit-tags'
55+
import { createLazyResult } from '../lib/lazy-result'
5556

5657
type CacheKeyParts =
5758
| [buildId: string, id: string, args: unknown[]]
@@ -694,6 +695,11 @@ export function cache(
694695
const implicitTags = workUnitStore?.implicitTags
695696
const forceRevalidate = shouldForceRevalidate(workStore, workUnitStore)
696697

698+
// Lazily refresh the tags for the cache handler that's associated with
699+
// this cache function. This is only done once per request and cache
700+
// handler, when it's awaited for the first time.
701+
await workStore.refreshTagsByCacheKind.get(kind)
702+
697703
let entry = forceRevalidate
698704
? undefined
699705
: 'getExpiration' in cacheHandler
@@ -706,7 +712,10 @@ export function cache(
706712
implicitTags?.tags ?? []
707713
)
708714

709-
if (entry && shouldDiscardCacheEntry(entry, workStore, implicitTags)) {
715+
if (
716+
entry &&
717+
(await shouldDiscardCacheEntry(entry, workStore, implicitTags))
718+
) {
710719
entry = undefined
711720
}
712721

@@ -880,25 +889,6 @@ export function cache(
880889
return React.cache(cachedFn)
881890
}
882891

883-
/**
884-
* Calls the given function only when the returned promise is awaited.
885-
*/
886-
function createLazyResult<TResult>(
887-
fn: () => Promise<TResult>
888-
): PromiseLike<TResult> {
889-
let pendingResult: Promise<TResult> | undefined
890-
891-
return {
892-
then(onfulfilled, onrejected) {
893-
if (!pendingResult) {
894-
pendingResult = fn()
895-
}
896-
897-
return pendingResult.then(onfulfilled, onrejected)
898-
},
899-
}
900-
}
901-
902892
function isPageComponent(
903893
args: any[]
904894
): args is [UseCachePageComponentProps, undefined] {
@@ -937,11 +927,11 @@ function shouldForceRevalidate(
937927
return false
938928
}
939929

940-
function shouldDiscardCacheEntry(
930+
async function shouldDiscardCacheEntry(
941931
entry: CacheEntry,
942932
workStore: WorkStore,
943933
implicitTags: ImplicitTags | undefined
944-
): boolean {
934+
): Promise<boolean> {
945935
// If the cache entry contains revalidated tags that the cache handler might
946936
// not know about yet, we need to discard it.
947937
if (entry.tags.some((tag) => isRecentlyRevalidatedTag(tag, workStore))) {
@@ -951,7 +941,7 @@ function shouldDiscardCacheEntry(
951941
if (implicitTags) {
952942
// If the cache entry was created before any of the implicit tags were
953943
// revalidated last, we also need to discard it.
954-
if (entry.timestamp <= implicitTags.expiration) {
944+
if (entry.timestamp <= (await implicitTags.expiration)) {
955945
return true
956946
}
957947

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function Page() {
2+
return <p>This page does not use "use cache".</p>
3+
}

Diff for: test/e2e/app-dir/use-cache-custom-handler/use-cache-custom-handler.test.ts

+30-17
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,9 @@ describe('use-cache-custom-handler', () => {
2323
const initialData = await browser.elementById('data').text()
2424
expect(initialData).toMatch(isoDateRegExp)
2525

26-
expect(next.cliOutput.slice(outputIndex)).toContain(
27-
'ModernCustomCacheHandler::refreshTags'
28-
)
26+
const cliOutput = next.cliOutput.slice(outputIndex)
2927

30-
expect(next.cliOutput.slice(outputIndex)).toContain(
31-
`ModernCustomCacheHandler::getExpiration ["_N_T_/layout","_N_T_/page","_N_T_/"]`
32-
)
28+
expect(cliOutput).toContain('ModernCustomCacheHandler::refreshTags')
3329

3430
expect(next.cliOutput.slice(outputIndex)).toMatch(
3531
/ModernCustomCacheHandler::get \["(development|[A-Za-z0-9_-]{21})","([0-9a-f]{2})+",\[\]\]/
@@ -39,13 +35,26 @@ describe('use-cache-custom-handler', () => {
3935
/ModernCustomCacheHandler::set \["(development|[A-Za-z0-9_-]{21})","([0-9a-f]{2})+",\[\]\]/
4036
)
4137

38+
// Since no existing cache entry was retrieved, we don't need to call
39+
// getExpiration() to compare the cache entries timestamp with the
40+
// expiration of the implicit tags.
41+
expect(cliOutput).not.toContain(`ModernCustomCacheHandler::getExpiration`)
42+
4243
// The data should be cached initially.
4344

45+
outputIndex = next.cliOutput.length
4446
await browser.refresh()
4547
let data = await browser.elementById('data').text()
4648
expect(data).toMatch(isoDateRegExp)
4749
expect(data).toEqual(initialData)
4850

51+
// Now that a cache entry exists, we expect that getExpiration() is called
52+
// to compare the cache entries timestamp with the expiration of the
53+
// implicit tags.
54+
expect(next.cliOutput.slice(outputIndex)).toContain(
55+
`ModernCustomCacheHandler::getExpiration ["_N_T_/layout","_N_T_/page","_N_T_/"]`
56+
)
57+
4958
// Because we use a low `revalidate` value for the "use cache" function, new
5059
// data should be returned eventually.
5160

@@ -57,6 +66,19 @@ describe('use-cache-custom-handler', () => {
5766
}, 5000)
5867
})
5968

69+
it('calls neither refreshTags nor getExpiration if "use cache" is not used', async () => {
70+
await next.fetch(`/no-cache`)
71+
const cliOutput = next.cliOutput.slice(outputIndex)
72+
73+
expect(cliOutput).not.toContain('ModernCustomCacheHandler::refreshTags')
74+
expect(cliOutput).not.toContain(`ModernCustomCacheHandler::getExpiration`)
75+
76+
// We don't optimize for legacy cache handlers though:
77+
expect(cliOutput).toContain(
78+
`LegacyCustomCacheHandler::receiveExpiredTags []`
79+
)
80+
})
81+
6082
it('should use a legacy custom cache handler if provided', async () => {
6183
const browser = await next.browser(`/legacy`)
6284
const initialData = await browser.elementById('data').text()
@@ -142,29 +164,20 @@ describe('use-cache-custom-handler', () => {
142164
await retry(async () => {
143165
const cliOutput = next.cliOutput.slice(outputIndex)
144166
expect(cliOutput).toInclude('ModernCustomCacheHandler::refreshTags')
145-
expect(cliOutput).toInclude('ModernCustomCacheHandler::getExpiration')
146167
expect(cliOutput).not.toInclude('ModernCustomCacheHandler::expireTags')
147168
})
148169
})
149170

150-
it('should not call getExpiration again after an action', async () => {
171+
it('should not call getExpiration after an action', async () => {
151172
const browser = await next.browser(`/`)
152173

153-
await retry(async () => {
154-
const cliOutput = next.cliOutput.slice(outputIndex)
155-
expect(cliOutput).toInclude('ModernCustomCacheHandler::getExpiration')
156-
})
157-
158174
outputIndex = next.cliOutput.length
159175

160176
await browser.elementById('revalidate-tag').click()
161177

162178
await retry(async () => {
163179
const cliOutput = next.cliOutput.slice(outputIndex)
164-
expect(cliOutput).toIncludeRepeated(
165-
'ModernCustomCacheHandler::getExpiration',
166-
1
167-
)
180+
expect(cliOutput).not.toInclude('ModernCustomCacheHandler::getExpiration')
168181
expect(cliOutput).toIncludeRepeated(
169182
`ModernCustomCacheHandler::expireTags`,
170183
1

0 commit comments

Comments
 (0)