Repository: vercel/next.js
Pull request: Avoid microtaskiness when lazily fetching from cache handlers


Changed files:
filename: packages/next/src/server/app-render/work-async-storage.external.ts
patch: @@ -9,6 +9,7 @@ import type { CacheLife } from '../use-cache/cache-life'
 
 // Share the instance module in the next-shared layer
 import { workAsyncStorageInstance } from './work-async-storage-instance' with { 'turbopack-transition': 'next-shared' }
+import type { LazyResult } from '../lib/lazy-result'
 
 export interface WorkStore {
   readonly isStaticGeneration: boolean
@@ -74,11 +75,11 @@ export interface WorkStore {
   readonly previouslyRevalidatedTags: readonly string[]
 
   /**
-   * This map contains promise-like values so that we can evaluate them lazily
-   * when a cache entry is read. It allows us to skip refreshing tags if no
-   * caches are read at all.
+   * This map contains lazy results so that we can evaluate them when the first
+   * cache entry is read. It allows us to skip refreshing tags if no caches are
+   * read at all.
    */
-  readonly refreshTagsByCacheKind: Map<string, PromiseLike<void>>
+  readonly refreshTagsByCacheKind: Map<string, LazyResult<void>>
 
   fetchMetrics?: FetchMetrics
 

filename: packages/next/src/server/async-storage/work-store.ts
patch: @@ -10,7 +10,7 @@ import type { CacheLife } from '../use-cache/cache-life'
 import { AfterContext } from '../after/after-context'
 
 import { normalizeAppPath } from '../../shared/lib/router/utils/app-paths'
-import { createLazyResult } from '../lib/lazy-result'
+import { createLazyResult, type LazyResult } from '../lib/lazy-result'
 import { getCacheHandlerEntries } from '../use-cache/handlers'
 
 export type WorkStoreContext = {
@@ -156,11 +156,11 @@ function createAfterContext(renderOpts: RequestLifecycleOpts): AfterContext {
 }
 
 /**
- * Creates a map with promise-like objects, that refresh tags for the given
- * cache kind when they're awaited for the first time.
+ * Creates a map with lazy results that refresh tags for the respective cache
+ * kind when they're awaited for the first time.
  */
-function createRefreshTagsByCacheKind(): Map<string, PromiseLike<void>> {
-  const refreshTagsByCacheKind = new Map<string, PromiseLike<void>>()
+function createRefreshTagsByCacheKind(): Map<string, LazyResult<void>> {
+  const refreshTagsByCacheKind = new Map<string, LazyResult<void>>()
   const cacheHandlers = getCacheHandlerEntries()
 
   if (cacheHandlers) {

filename: packages/next/src/server/lib/implicit-tags.ts
patch: @@ -1,22 +1,25 @@
 import { NEXT_CACHE_IMPLICIT_TAG_ID } from '../../lib/constants'
 import type { FallbackRouteParams } from '../request/fallback-params'
-import { getCacheHandlers } from '../use-cache/handlers'
-import { createLazyResult } from './lazy-result'
+import { getCacheHandlerEntries } from '../use-cache/handlers'
+import { createLazyResult, type LazyResult } from './lazy-result'
 
 export interface ImplicitTags {
   /**
    * For legacy usage, the implicit tags are passed to the incremental cache
    * handler in `get` calls.
    */
   readonly tags: string[]
+
   /**
    * Modern cache handlers don't receive implicit tags. Instead, the implicit
-   * tags' expiration is stored in the work unit store, and used to compare with
-   * a cache entry's timestamp. Note: This is a promise-like value so that we
-   * can evaluate it lazily when a cache entry is read. It allows us to skip
-   * fetching the expiration value if no caches are read at all.
+   * tags' expirations are stored in the work unit store, and used to compare
+   * with a cache entry's timestamp.
+   *
+   * Note: This map contains lazy results so that we can evaluate them when the
+   * first cache entry is read. It allows us to skip fetching the expiration
+   * values if no caches are read at all.
    */
-  readonly expiration: PromiseLike<number>
+  readonly expirationsByCacheKind: Map<string, LazyResult<number>>
 }
 
 const getDerivedTags = (pathname: string): string[] => {
@@ -44,34 +47,28 @@ const getDerivedTags = (pathname: string): string[] => {
   return derivedTags
 }
 
-async function getImplicitTagsExpiration(tags: string[]): Promise<number> {
-  // We're starting off with assuming that implicit tags are not expired, so we
-  // use an artificial timestamp of 0.
-  let expiration = 0
-
-  const cacheHandlers = getCacheHandlers()
+/**
+ * Creates a map with lazy results that fetch the expiration value for the given
+ * tags and respective cache kind when they're awaited for the first time.
+ */
+function createTagsExpirationsByCacheKind(
+  tags: string[]
+): Map<string, LazyResult<number>> {
+  const expirationsByCacheKind = new Map<string, LazyResult<number>>()
+  const cacheHandlers = getCacheHandlerEntries()
 
   if (cacheHandlers) {
-    const expirations = await Promise.all(
-      [...cacheHandlers].map(async (handler) => {
-        if ('getExpiration' in handler) {
-          return handler.getExpiration(...tags)
-        }
-
-        // Use 0 as fallback of legacy cache handlers. We don't need to track
-        // the expiration of implicit tags for those, because they're passed
-        // into the `get()` method and are checked internally by the cache
-        // handler.
-        return 0
-      })
-    )
-
-    // We use the most recent expiration from all cache handlers, i.e. the
-    // largest timestamp. Semantically, they should all be the same though.
-    expiration = Math.max(...expirations)
+    for (const [kind, cacheHandler] of cacheHandlers) {
+      if ('getExpiration' in cacheHandler) {
+        expirationsByCacheKind.set(
+          kind,
+          createLazyResult(async () => cacheHandler.getExpiration(...tags))
+        )
+      }
+    }
   }
 
-  return expiration
+  return expirationsByCacheKind
 }
 
 export async function getImplicitTags(
@@ -82,7 +79,6 @@ export async function getImplicitTags(
   },
   fallbackRouteParams: null | FallbackRouteParams
 ): Promise<ImplicitTags> {
-  // TODO: Cache the result
   const tags: string[] = []
   const hasFallbackRouteParams =
     fallbackRouteParams && fallbackRouteParams.size > 0
@@ -101,9 +97,8 @@ export async function getImplicitTags(
     tags.push(tag)
   }
 
-  const expiration = createLazyResult(async () =>
-    getImplicitTagsExpiration(tags)
-  )
-
-  return { tags, expiration }
+  return {
+    tags,
+    expirationsByCacheKind: createTagsExpirationsByCacheKind(tags),
+  }
 }

filename: packages/next/src/server/lib/lazy-result.ts
patch: @@ -1,19 +1,41 @@
+export type LazyResult<TValue> = PromiseLike<TValue> & { value?: TValue }
+export type ResolvedLazyResult<TValue> = PromiseLike<TValue> & { value: TValue }
+
 /**
  * Calls the given async function only when the returned promise-like object is
- * awaited.
+ * awaited. Afterwards, it provides the resolved value synchronously as `value`
+ * property.
  */
-export function createLazyResult<TResult>(
-  fn: () => Promise<TResult>
-): PromiseLike<TResult> {
-  let pendingResult: Promise<TResult> | undefined
+export function createLazyResult<TValue>(
+  fn: () => Promise<TValue>
+): LazyResult<TValue> {
+  let pendingResult: Promise<TValue> | undefined
 
-  return {
+  const result: LazyResult<TValue> = {
     then(onfulfilled, onrejected) {
       if (!pendingResult) {
         pendingResult = fn()
       }
 
+      pendingResult
+        .then((value) => {
+          result.value = value
+        })
+        .catch(() => {
+          // The externally awaited result will be rejected via `onrejected`. We
+          // don't need to handle it here. But we do want to avoid an unhandled
+          // rejection.
+        })
+
       return pendingResult.then(onfulfilled, onrejected)
     },
   }
+
+  return result
+}
+
+export function isResolvedLazyResult<TValue>(
+  result: LazyResult<TValue>
+): result is ResolvedLazyResult<TValue> {
+  return result.hasOwnProperty('value')
 }

filename: packages/next/src/server/use-cache/use-cache-wrapper.ts
patch: @@ -51,8 +51,7 @@ import {
 } from '../request/search-params'
 import type { Params } from '../request/params'
 import React from 'react'
-import type { ImplicitTags } from '../lib/implicit-tags'
-import { createLazyResult } from '../lib/lazy-result'
+import { createLazyResult, isResolvedLazyResult } from '../lib/lazy-result'
 
 type CacheKeyParts =
   | [buildId: string, id: string, args: unknown[]]
@@ -702,15 +701,13 @@ export function cache(
           cacheSignal.beginRead()
         }
 
-        const implicitTags = workUnitStore?.implicitTags
-        const forceRevalidate = shouldForceRevalidate(workStore, workUnitStore)
+        const lazyRefreshTags = workStore.refreshTagsByCacheKind.get(kind)
 
-        // Lazily refresh the tags for the cache handler that's associated with
-        // this cache function. This is only done once per request and cache
-        // handler, when it's awaited for the first time.
-        await workStore.refreshTagsByCacheKind.get(kind)
+        if (lazyRefreshTags && !isResolvedLazyResult(lazyRefreshTags)) {
+          await lazyRefreshTags
+        }
 
-        let entry = forceRevalidate
+        let entry = shouldForceRevalidate(workStore, workUnitStore)
           ? undefined
           : 'getExpiration' in cacheHandler
             ? await cacheHandler.get(serializedCacheKey)
@@ -719,15 +716,37 @@ export function cache(
               // cache handlers (see below).
               await cacheHandler.get(
                 serializedCacheKey,
-                implicitTags?.tags ?? []
+                workUnitStore?.implicitTags?.tags ?? []
               )
 
-        if (
-          entry &&
-          (await shouldDiscardCacheEntry(entry, workStore, implicitTags))
-        ) {
-          debug?.('discarding stale entry', serializedCacheKey)
-          entry = undefined
+        if (entry) {
+          const implicitTags = workUnitStore?.implicitTags?.tags ?? []
+          let implicitTagsExpiration = 0
+
+          if (workUnitStore?.implicitTags) {
+            const lazyExpiration =
+              workUnitStore.implicitTags.expirationsByCacheKind.get(kind)
+
+            if (lazyExpiration) {
+              if (isResolvedLazyResult(lazyExpiration)) {
+                implicitTagsExpiration = lazyExpiration.value
+              } else {
+                implicitTagsExpiration = await lazyExpiration
+              }
+            }
+          }
+
+          if (
+            shouldDiscardCacheEntry(
+              entry,
+              workStore,
+              implicitTags,
+              implicitTagsExpiration
+            )
+          ) {
+            debug?.('discarding stale entry', serializedCacheKey)
+            entry = undefined
+          }
         }
 
         const currentTime = performance.timeOrigin + performance.now()
@@ -951,38 +970,35 @@ function shouldForceRevalidate(
   return false
 }
 
-async function shouldDiscardCacheEntry(
+function shouldDiscardCacheEntry(
   entry: CacheEntry,
   workStore: WorkStore,
-  implicitTags: ImplicitTags | undefined
-): Promise<boolean> {
+  implicitTags: string[],
+  implicitTagsExpiration: number
+): boolean {
   // If the cache entry contains revalidated tags that the cache handler might
   // not know about yet, we need to discard it.
   if (entry.tags.some((tag) => isRecentlyRevalidatedTag(tag, workStore))) {
     return true
   }
 
-  if (implicitTags) {
-    // If the cache entry was created before any of the implicit tags were
-    // revalidated last, we also need to discard it.
-    if (entry.timestamp <= (await implicitTags.expiration)) {
-      debug?.(
-        'entry was created at',
-        entry.timestamp,
-        'before implicit tags were revalidated at',
-        implicitTags.expiration
-      )
+  // If the cache entry was created before any of the implicit tags were
+  // revalidated last, we also need to discard it.
+  if (entry.timestamp <= implicitTagsExpiration) {
+    debug?.(
+      'entry was created at',
+      entry.timestamp,
+      'before implicit tags were revalidated at',
+      implicitTagsExpiration
+    )
 
-      return true
-    }
+    return true
+  }
 
-    // Finally, if any of the implicit tags have been revalidated recently, we
-    // also need to discard the cache entry.
-    if (
-      implicitTags.tags.some((tag) => isRecentlyRevalidatedTag(tag, workStore))
-    ) {
-      return true
-    }
+  // Finally, if any of the implicit tags have been revalidated recently, we
+  // also need to discard the cache entry.
+  if (implicitTags.some((tag) => isRecentlyRevalidatedTag(tag, workStore))) {
+    return true
   }
 
   return false