next.js/packages/next/src/server/revalidation-utils.ts
revalidation-utils.ts222 lines6.2 KB
import type { WorkStore } from './app-render/work-async-storage.external'
import type { IncrementalCache } from './lib/incremental-cache'
import { getCacheHandlers } from './use-cache/handlers'

/** Run a callback, and execute any *new* revalidations added during its runtime. */
export async function withExecuteRevalidates<T>(
  store: WorkStore | undefined,
  callback: () => Promise<T>
): Promise<T> {
  if (!store) {
    return callback()
  }
  // If we executed any revalidates during the request, then we don't want to execute them again.
  // save the state so we can check if anything changed after we're done running callbacks.
  const savedRevalidationState = cloneRevalidationState(store)
  try {
    return await callback()
  } finally {
    // Check if we have any new revalidates, and if so, wait until they are all resolved.
    const newRevalidates = diffRevalidationState(
      savedRevalidationState,
      cloneRevalidationState(store)
    )
    await executeRevalidates(store, newRevalidates)
  }
}

type RevalidationState = Required<
  Pick<
    WorkStore,
    'pendingRevalidatedTags' | 'pendingRevalidates' | 'pendingRevalidateWrites'
  >
>

function cloneRevalidationState(store: WorkStore): RevalidationState {
  return {
    pendingRevalidatedTags: store.pendingRevalidatedTags
      ? [...store.pendingRevalidatedTags]
      : [],
    pendingRevalidates: { ...store.pendingRevalidates },
    pendingRevalidateWrites: store.pendingRevalidateWrites
      ? [...store.pendingRevalidateWrites]
      : [],
  }
}

function diffRevalidationState(
  prev: RevalidationState,
  curr: RevalidationState
): RevalidationState {
  const prevTagsWithProfile = new Set(
    prev.pendingRevalidatedTags.map((item) => {
      const profileKey =
        typeof item.profile === 'object'
          ? JSON.stringify(item.profile)
          : item.profile || ''
      return `${item.tag}:${profileKey}`
    })
  )
  const prevRevalidateWrites = new Set(prev.pendingRevalidateWrites)
  return {
    pendingRevalidatedTags: curr.pendingRevalidatedTags.filter((item) => {
      const profileKey =
        typeof item.profile === 'object'
          ? JSON.stringify(item.profile)
          : item.profile || ''
      return !prevTagsWithProfile.has(`${item.tag}:${profileKey}`)
    }),
    pendingRevalidates: Object.fromEntries(
      Object.entries(curr.pendingRevalidates).filter(
        ([key]) => !(key in prev.pendingRevalidates)
      )
    ),
    pendingRevalidateWrites: curr.pendingRevalidateWrites.filter(
      (promise) => !prevRevalidateWrites.has(promise)
    ),
  }
}

async function revalidateTags(
  tagsWithProfile: Array<{
    tag: string
    profile?: string | { expire?: number }
  }>,
  incrementalCache: IncrementalCache | undefined,
  workStore?: WorkStore
): Promise<void> {
  if (tagsWithProfile.length === 0) {
    return
  }

  const handlers = getCacheHandlers()
  const promises: Promise<void>[] = []

  // Group tags by profile for batch processing
  const tagsByProfile = new Map<
    | string
    | { stale?: number; revalidate?: number; expire?: number }
    | undefined,
    string[]
  >()

  for (const item of tagsWithProfile) {
    const profile = item.profile
    // Find existing profile by comparing values
    let existingKey = undefined
    for (const [key] of tagsByProfile) {
      if (
        typeof key === 'string' &&
        typeof profile === 'string' &&
        key === profile
      ) {
        existingKey = key
        break
      }
      if (
        typeof key === 'object' &&
        typeof profile === 'object' &&
        JSON.stringify(key) === JSON.stringify(profile)
      ) {
        existingKey = key
        break
      }
      if (key === profile) {
        existingKey = key
        break
      }
    }

    const profileKey = existingKey || profile
    if (!tagsByProfile.has(profileKey)) {
      tagsByProfile.set(profileKey, [])
    }
    tagsByProfile.get(profileKey)!.push(item.tag)
  }

  // Process each profile group
  for (const [profile, tagsForProfile] of tagsByProfile) {
    // Look up the cache profile from workStore if available
    let durations: { expire?: number } | undefined

    if (profile) {
      let cacheLife:
        | { stale?: number; revalidate?: number; expire?: number }
        | undefined

      if (typeof profile === 'object') {
        // Profile is already a cacheLife configuration object
        cacheLife = profile
      } else if (typeof profile === 'string') {
        // Profile is a string key, look it up in workStore
        cacheLife = workStore?.cacheLifeProfiles?.[profile]

        if (!cacheLife) {
          throw new Error(
            `Invalid profile provided "${profile}" must be configured under cacheLife in next.config or be "max"`
          )
        }
      }

      if (cacheLife) {
        durations = {
          expire: cacheLife.expire,
        }
      }
    }
    // If profile is not found and not 'max', durations will be undefined
    // which will trigger immediate expiration in the cache handler

    for (const handler of handlers || []) {
      if (profile) {
        promises.push(handler.updateTags?.(tagsForProfile, durations))
      } else {
        promises.push(handler.updateTags?.(tagsForProfile))
      }
    }

    if (incrementalCache) {
      promises.push(incrementalCache.revalidateTag(tagsForProfile, durations))
    }
  }

  await Promise.all(promises)
}

export function executeRevalidates(
  workStore: WorkStore,
  state?: RevalidationState
): false | Promise<void> {
  const promises: Promise<unknown>[] = []

  const pendingRevalidatedTags =
    state?.pendingRevalidatedTags ?? workStore.pendingRevalidatedTags ?? []

  if (pendingRevalidatedTags.length > 0) {
    promises.push(
      revalidateTags(
        pendingRevalidatedTags,
        workStore.incrementalCache,
        workStore
      )
    )
  }

  const pendingRevalidates = Object.values(
    state?.pendingRevalidates ?? workStore.pendingRevalidates ?? {}
  )

  promises.push(...pendingRevalidates)

  const pendingRevalidateWrites =
    state?.pendingRevalidateWrites ?? workStore.pendingRevalidateWrites ?? []

  promises.push(...pendingRevalidateWrites)

  if (promises.length === 0) {
    return false
  }

  return Promise.all(promises).then(() => undefined)
}
Quest for Codev2.0.0
/
SIGN IN