next.js/packages/next/src/server/app-render/vary-params.ts
vary-params.ts382 lines12.5 KB
import type { Params } from '../request/params'
import type { SearchParams } from '../request/search-params'
import { workUnitAsyncStorage } from './work-unit-async-storage.external'
import type {
  VaryParamsThenable,
  VaryParams,
} from '../../shared/lib/segment-cache/vary-params-decoding'

/**
 * Accumulates vary params for a single segment (or for metadata/rootParams).
 *
 * VaryParamsAccumulator is also a thenable that can be serialized by React
 * Flight. The accumulator starts as 'pending' and accumulates param accesses
 * during render. Call `finishTrackingVaryParams()` after rendering to resolve
 * all accumulators.
 *
 * The `status` and `value` fields follow the React Flight thenable protocol:
 * when `status === 'fulfilled'`, Flight can read `value` synchronously without
 * scheduling a microtask via `.then()`.
 */
export type VaryParamsAccumulator = {
  // Mutable during render - accumulates param access
  varyParams: VaryParams

  // React thenable protocol fields
  status: 'pending' | 'fulfilled'
  value: VaryParams
  then(
    onfulfilled?: ((value: Set<string>) => unknown) | null,
    onrejected?: ((reason: unknown) => unknown) | null
  ): void

  // Internal - callbacks waiting for resolution
  resolvers: Array<(value: Set<string>) => void>
}

/**
 * A mutable data structure for accumulating per-segment vary params for an
 * entire server response. It's only used during prerenders. It describes
 * metadata about the response itself.
 */
export type ResponseVaryParamsAccumulator = {
  /** Vary params accumulator for metadata/viewport (the "head" segment) */
  head: VaryParamsAccumulator
  /** Vary params accumulator for root params access */
  rootParams: VaryParamsAccumulator
  /** Vary params accumulators for each route segment */
  segments: Set<VaryParamsAccumulator>
}

function createSegmentVaryParamsAccumulator(): VaryParamsAccumulator {
  const accumulator: VaryParamsAccumulator = {
    varyParams: new Set(),
    status: 'pending',
    value: new Set(),
    then(onfulfilled: ((value: Set<string>) => unknown) | null | undefined) {
      if (onfulfilled) {
        if (accumulator.status === 'pending') {
          accumulator.resolvers.push(onfulfilled)
        } else {
          onfulfilled(accumulator.value)
        }
      }
    },
    resolvers: [],
  }
  return accumulator
}

/**
 * A singleton accumulator that's already resolved to an empty Set. Use this for
 * segments where we know upfront that no params will be accessed, such as
 * client components or segments without user code.
 *
 * Benefits:
 * - No need to accumulate or resolve later
 * - Resilient: resolves correctly even if other tracking fails
 * - Memory efficient: reuses the same object
 */
const emptySet: VaryParams = new Set()
export const emptyVaryParamsAccumulator: VaryParamsAccumulator = {
  varyParams: emptySet,
  status: 'fulfilled',
  value: emptySet,
  then(onfulfilled: ((value: Set<string>) => unknown) | null | undefined) {
    if (onfulfilled) {
      onfulfilled(emptySet)
    }
  },
  resolvers: [],
}

export function createResponseVaryParamsAccumulator(): ResponseVaryParamsAccumulator {
  // Create the head and rootParams accumulators as top-level fields.
  // Segment accumulators are added to the segments set as they are created.
  const head = createSegmentVaryParamsAccumulator()
  const rootParams = createSegmentVaryParamsAccumulator()
  const segments = new Set<VaryParamsAccumulator>()

  return {
    head,
    rootParams,
    segments,
  }
}

/**
 * Allocates a new VaryParamsAccumulator and adds it to the response accumulator
 * associated with the current WorkUnitStore.
 *
 * Returns a thenable that resolves to the segment's vary params once rendering
 * is complete. The thenable can be passed directly to React Flight for
 * serialization.
 */
export function createVaryParamsAccumulator(): VaryParamsAccumulator | null {
  const workUnitStore = workUnitAsyncStorage.getStore()
  if (workUnitStore) {
    switch (workUnitStore.type) {
      case 'prerender':
      case 'prerender-runtime':
      case 'request': {
        const responseAccumulator = workUnitStore.varyParamsAccumulator
        if (responseAccumulator) {
          const accumulator = createSegmentVaryParamsAccumulator()
          responseAccumulator.segments.add(accumulator)
          return accumulator
        }
        return null
      }
      case 'prerender-ppr':
      case 'prerender-legacy':
      case 'cache':
      case 'private-cache':
      case 'prerender-client':
      case 'validation-client':
      case 'unstable-cache':
      case 'generate-static-params':
        break
      default:
        workUnitStore satisfies never
    }
  }
  return null
}

export function getMetadataVaryParamsAccumulator(): VaryParamsAccumulator | null {
  const workUnitStore = workUnitAsyncStorage.getStore()
  if (workUnitStore) {
    switch (workUnitStore.type) {
      case 'prerender':
      case 'prerender-runtime':
      case 'request': {
        const responseAccumulator = workUnitStore.varyParamsAccumulator
        if (responseAccumulator) {
          return responseAccumulator.head
        }
        return null
      }
      case 'prerender-ppr':
      case 'prerender-legacy':
      case 'cache':
      case 'private-cache':
      case 'prerender-client':
      case 'validation-client':
      case 'unstable-cache':
      case 'generate-static-params':
        return null
      default:
        workUnitStore satisfies never
    }
  }
  return null
}

export function getVaryParamsThenable(
  accumulator: VaryParamsAccumulator
): VaryParamsThenable | null {
  return accumulator as unknown as VaryParamsThenable | null
}

export function getMetadataVaryParamsThenable(): VaryParamsThenable | null {
  const accumulator = getMetadataVaryParamsAccumulator()
  if (accumulator !== null) {
    return getVaryParamsThenable(accumulator)
  }
  return null
}

// The metadata and viewport are always delivered in a single payload, so they
// don't need to be tracked separately. This may change in the future, but for
// now this is just an alias.
export const getViewportVaryParamsAccumulator = getMetadataVaryParamsAccumulator

export function getRootParamsVaryParamsAccumulator(): VaryParamsAccumulator | null {
  const workUnitStore = workUnitAsyncStorage.getStore()
  if (workUnitStore) {
    switch (workUnitStore.type) {
      case 'prerender':
      case 'prerender-runtime': {
        const responseAccumulator = workUnitStore.varyParamsAccumulator
        if (responseAccumulator !== null) {
          return responseAccumulator.rootParams
        }
        return null
      }
      case 'prerender-ppr':
      case 'prerender-legacy':
      case 'request':
      case 'cache':
      case 'private-cache':
      case 'prerender-client':
      case 'validation-client':
      case 'unstable-cache':
      case 'generate-static-params':
        return null
      default:
        workUnitStore satisfies never
    }
  }
  return null
}

/**
 * Records that a param was accessed. Adds the param name to the accumulator's
 * varyParams set.
 */
export function accumulateVaryParam(
  accumulator: VaryParamsAccumulator,
  paramName: string
): void {
  accumulator.varyParams.add(paramName)
}

/**
 * Records a root param access.
 */
export function accumulateRootVaryParam(paramName: string): void {
  const rootParamsAccumulator = getRootParamsVaryParamsAccumulator()
  if (rootParamsAccumulator !== null) {
    accumulateVaryParam(rootParamsAccumulator, paramName)
  }
}

export function createVaryingParams(
  accumulator: VaryParamsAccumulator,
  originalParamsObject: Params,
  optionalCatchAllParamName: string | null
): Params {
  if (optionalCatchAllParamName !== null) {
    // When there's an optional catch-all param with no value (e.g.,
    // [[...slug]] at /), the param doesn't exist as a property on the params
    // object. Use a Proxy to track all param access — both existing params
    // and the missing optional param — including enumeration patterns like
    // Object.keys(), spread, for...in, and `in` checks.
    return new Proxy(originalParamsObject, {
      get(target, prop, receiver) {
        if (typeof prop === 'string') {
          if (
            prop === optionalCatchAllParamName ||
            Object.prototype.hasOwnProperty.call(target, prop)
          ) {
            accumulateVaryParam(accumulator, prop)
          }
        }
        return Reflect.get(target, prop, receiver)
      },
      has(target, prop) {
        if (prop === optionalCatchAllParamName) {
          accumulateVaryParam(accumulator, optionalCatchAllParamName)
        }
        return Reflect.has(target, prop)
      },
      ownKeys(target) {
        // Enumerating the params object means the user's code may depend on
        // which params are present, so conservatively track the optional
        // param as accessed.
        accumulateVaryParam(accumulator, optionalCatchAllParamName)
        return Reflect.ownKeys(target)
      },
    })
  }

  // When there's no optional catch-all, all params exist as properties on the
  // object, so we can use defineProperty getters instead of a Proxy. This is
  // faster because the engine can optimize property access on regular objects
  // more aggressively than Proxy trap calls.
  const underlyingParamsWithVarying: Params = {}
  for (const paramName in originalParamsObject) {
    Object.defineProperty(underlyingParamsWithVarying, paramName, {
      get() {
        accumulateVaryParam(accumulator, paramName)
        return originalParamsObject[paramName]
      },
      enumerable: true,
    })
  }
  return underlyingParamsWithVarying
}

export function createVaryingSearchParams(
  accumulator: VaryParamsAccumulator,
  originalSearchParamsObject: SearchParams
): SearchParams {
  const underlyingSearchParamsWithVarying: SearchParams = {}
  for (const searchParamName in originalSearchParamsObject) {
    Object.defineProperty(underlyingSearchParamsWithVarying, searchParamName, {
      get() {
        // TODO: Unlike path params, we don't vary track each search param
        // individually. The entire search string is treated as a single param.
        // This may change in the future.
        accumulateVaryParam(accumulator, '?')
        return originalSearchParamsObject[searchParamName]
      },
      enumerable: true,
    })
  }
  return underlyingSearchParamsWithVarying
}

/**
 * Resolves all segment accumulators in a ResponseVaryParamsAccumulator with
 * their final vary params. Call this after rendering is complete.
 *
 * Each segment's thenable is resolved with its vary params merged with the
 * root params. If we can't track vary params (e.g., legacy prerender), simply
 * don't call this function - the client treats unresolved thenables as
 * "unknown" vary params.
 */
export async function finishAccumulatingVaryParams(
  responseAccumulator: ResponseVaryParamsAccumulator
): Promise<void> {
  const rootVaryParams = responseAccumulator.rootParams.varyParams

  // Resolve head
  finishSegmentAccumulator(responseAccumulator.head, rootVaryParams)

  // Resolve each segment
  for (const segmentAccumulator of responseAccumulator.segments) {
    finishSegmentAccumulator(segmentAccumulator, rootVaryParams)
  }

  // Now that the thenables are resolved, Flight should be able to flush the
  // vary params into the response stream. This work gets scheduled internally
  // by Flight using a microtask as soon as we notify the thenable listeners.
  //
  // We need to ensure that Flight's pending queues are emptied before this
  // function returns; the caller will abort the prerender immediately after.
  // We can't use a macrotask, because that would allow dynamic IO to sneak
  // into the response. So we use microtasks instead.
  //
  // The exact number of awaits here isn't important (indeed, one seems to be
  // sufficient, at the time of writing), as long as we wait enough ticks for
  // Flight to finish writing the response.
  //
  // Anything that remains in Flight's internal queue after these awaits must
  // be actual dynamic IO, not caused by pending vary params tasks. In other
  // words, failing to do this would cause us to treat a fully static prerender
  // as if it were partially dynamic.
  await Promise.resolve()
  await Promise.resolve()
  await Promise.resolve()
}

function finishSegmentAccumulator(
  accumulator: VaryParamsAccumulator,
  rootVaryParams: VaryParams
): void {
  if (accumulator.status !== 'pending') {
    return
  }
  const merged = new Set<string>(accumulator.varyParams)
  for (const param of rootVaryParams) {
    merged.add(param)
  }
  accumulator.value = merged
  accumulator.status = 'fulfilled'
  for (const resolver of accumulator.resolvers) {
    resolver(merged)
  }
  accumulator.resolvers = []
}
Quest for Codev2.0.0
/
SIGN IN