next.js/packages/next/src/server/request/search-params.ts
search-params.ts725 lines24.0 KB
import {
  workAsyncStorage,
  type WorkStore,
} from '../app-render/work-async-storage.external'
import type { VaryParamsAccumulator } from '../app-render/vary-params'
import {
  createVaryingSearchParams,
  getMetadataVaryParamsAccumulator,
} from '../app-render/vary-params'

import { ReflectAdapter } from '../web/spec-extension/adapters/reflect'
import {
  throwToInterruptStaticGeneration,
  postponeWithTracking,
  annotateDynamicAccess,
} from '../app-render/dynamic-rendering'

import {
  workUnitAsyncStorage,
  type PrerenderStoreLegacy,
  type PrerenderStorePPR,
  type PrerenderStoreModern,
  type PrerenderStoreModernRuntime,
  type StaticPrerenderStore,
  throwInvariantForMissingStore,
  type RequestStore,
  type ValidationStoreClient,
} from '../app-render/work-unit-async-storage.external'
import { InvariantError } from '../../shared/lib/invariant-error'
import {
  makeDevtoolsIOAwarePromise,
  makeHangingPromise,
} from '../dynamic-rendering-utils'
import { createDedupedByCallsiteServerErrorLoggerDev } from '../create-deduped-by-callsite-server-error-logger'
import {
  describeStringPropertyAccess,
  describeHasCheckingStringProperty,
  wellKnownProperties,
} from '../../shared/lib/utils/reflect-utils'
import {
  throwWithStaticGenerationBailoutErrorWithDynamicError,
  throwForSearchParamsAccessInUseCache,
} from './utils'
import { RenderStage } from '../app-render/staged-rendering'

export type SearchParams = { [key: string]: string | string[] | undefined }

export function createSearchParamsFromClient(
  underlyingSearchParams: SearchParams
): Promise<SearchParams> {
  const workStore = workAsyncStorage.getStore()
  if (!workStore) {
    throw new InvariantError('Expected workStore to be initialized')
  }
  const workUnitStore = workUnitAsyncStorage.getStore()
  if (workUnitStore) {
    switch (workUnitStore.type) {
      case 'prerender':
      case 'prerender-client':
      case 'prerender-ppr':
      case 'prerender-legacy':
        return createStaticPrerenderSearchParams(workStore, workUnitStore)
      case 'validation-client': {
        return createClientSearchParamsInValidation(
          underlyingSearchParams,
          workStore,
          workUnitStore
        )
      }
      case 'prerender-runtime':
        throw new InvariantError(
          'createSearchParamsFromClient should not be called in a runtime prerender.'
        )
      case 'cache':
      case 'private-cache':
      case 'unstable-cache':
        throw new InvariantError(
          'createSearchParamsFromClient should not be called in cache contexts.'
        )
      case 'generate-static-params':
        throw new InvariantError(
          'createSearchParamsFromClient should not be called inside generateStaticParams.'
        )
      case 'request':
        // Client searchParams are not runtime prefetchable
        const isRuntimePrefetchable = false
        return createRenderSearchParams(
          underlyingSearchParams,
          workStore,
          workUnitStore,
          isRuntimePrefetchable
        )
      default:
        workUnitStore satisfies never
    }
  }
  throwInvariantForMissingStore()
}

// generateMetadata always runs in RSC context so it is equivalent to a Server Page Component
export function createServerSearchParamsForMetadata(
  underlyingSearchParams: SearchParams,
  isRuntimePrefetchable: boolean
): Promise<SearchParams> {
  const metadataVaryParamsAccumulator = getMetadataVaryParamsAccumulator()
  return createServerSearchParamsForServerPage(
    underlyingSearchParams,
    metadataVaryParamsAccumulator,
    isRuntimePrefetchable
  )
}

export function createServerSearchParamsForServerPage(
  underlyingSearchParams: SearchParams,
  varyParamsAccumulator: VaryParamsAccumulator | null,
  isRuntimePrefetchable: boolean
): Promise<SearchParams> {
  const workStore = workAsyncStorage.getStore()
  if (!workStore) {
    throw new InvariantError('Expected workStore to be initialized')
  }
  const workUnitStore = workUnitAsyncStorage.getStore()
  if (workUnitStore) {
    switch (workUnitStore.type) {
      case 'prerender':
      case 'prerender-client':
      case 'prerender-ppr':
      case 'prerender-legacy':
        return createStaticPrerenderSearchParams(workStore, workUnitStore)
      case 'validation-client':
        throw new InvariantError(
          'createServerSearchParamsForServerPage should not be called in a client validation.'
        )
      case 'cache':
      case 'private-cache':
      case 'unstable-cache':
        throw new InvariantError(
          'createServerSearchParamsForServerPage should not be called in cache contexts.'
        )
      case 'generate-static-params':
        throw new InvariantError(
          'createServerSearchParamsForServerPage should not be called inside generateStaticParams.'
        )
      case 'prerender-runtime':
        return createRuntimePrerenderSearchParams(
          underlyingSearchParams,
          workUnitStore,
          varyParamsAccumulator,
          isRuntimePrefetchable
        )
      case 'request':
        return createRenderSearchParams(
          underlyingSearchParams,
          workStore,
          workUnitStore,
          isRuntimePrefetchable
        )
      default:
        workUnitStore satisfies never
    }
  }
  throwInvariantForMissingStore()
}

export function createPrerenderSearchParamsForClientPage(): Promise<SearchParams> {
  const workStore = workAsyncStorage.getStore()
  if (!workStore) {
    throw new InvariantError('Expected workStore to be initialized')
  }
  if (workStore.forceStatic) {
    // When using forceStatic we override all other logic and always just return an empty
    // dictionary object.
    return Promise.resolve({})
  }

  const workUnitStore = workUnitAsyncStorage.getStore()
  if (workUnitStore) {
    switch (workUnitStore.type) {
      case 'prerender':
      case 'prerender-client':
        // We're prerendering in a mode that aborts (cacheComponents) and should stall
        // the promise to ensure the RSC side is considered dynamic
        return makeHangingPromise(
          workUnitStore.renderSignal,
          workStore.route,
          '`searchParams`'
        )
      case 'validation-client':
        throw new InvariantError(
          'createPrerenderSearchParamsForClientPage should not be called in a client validation.'
        )
      case 'prerender-runtime':
        throw new InvariantError(
          'createPrerenderSearchParamsForClientPage should not be called in a runtime prerender.'
        )
      case 'cache':
      case 'private-cache':
      case 'unstable-cache':
        throw new InvariantError(
          'createPrerenderSearchParamsForClientPage should not be called in cache contexts.'
        )
      case 'generate-static-params':
        throw new InvariantError(
          'createPrerenderSearchParamsForClientPage should not be called inside generateStaticParams.'
        )
      case 'prerender-ppr':
      case 'prerender-legacy':
      case 'request':
        return Promise.resolve({})
      default:
        workUnitStore satisfies never
    }
  }
  throwInvariantForMissingStore()
}

function createStaticPrerenderSearchParams(
  workStore: WorkStore,
  prerenderStore: StaticPrerenderStore
): Promise<SearchParams> {
  if (workStore.forceStatic) {
    // When using forceStatic we override all other logic and always just return an empty
    // dictionary object.
    return Promise.resolve({})
  }

  switch (prerenderStore.type) {
    case 'prerender':
    case 'prerender-client':
      // We are in a cacheComponents (PPR or otherwise) prerender
      return makeHangingSearchParams(workStore, prerenderStore)
    case 'prerender-ppr':
    case 'prerender-legacy':
      // We are in a legacy static generation and need to interrupt the
      // prerender when search params are accessed.
      return makeErroringSearchParams(workStore, prerenderStore)
    default:
      return prerenderStore satisfies never
  }
}

function createRuntimePrerenderSearchParams(
  underlyingSearchParams: SearchParams,
  workUnitStore: PrerenderStoreModernRuntime,
  varyParamsAccumulator: VaryParamsAccumulator | null,
  isRuntimePrefetchable: boolean
): Promise<SearchParams> {
  const underlyingSearchParamsWithVarying =
    varyParamsAccumulator !== null
      ? createVaryingSearchParams(varyParamsAccumulator, underlyingSearchParams)
      : underlyingSearchParams

  const result = makeUntrackedSearchParams(underlyingSearchParamsWithVarying)
  const { stagedRendering } = workUnitStore
  if (!stagedRendering) {
    return result
  }
  const stage = isRuntimePrefetchable
    ? RenderStage.EarlyRuntime
    : RenderStage.Runtime
  return stagedRendering.waitForStage(stage).then(() => result)
}

function createRenderSearchParams(
  underlyingSearchParams: SearchParams,
  workStore: WorkStore,
  requestStore: RequestStore,
  isRuntimePrefetchable: boolean
): Promise<SearchParams> {
  if (workStore.forceStatic) {
    // When using forceStatic we override all other logic and always just return an empty
    // dictionary object.
    return Promise.resolve({})
  } else {
    if (process.env.NODE_ENV === 'development') {
      // Semantically we only need the dev tracking when running in `next dev`
      // but since you would never use next dev with production NODE_ENV we use this
      // as a proxy so we can statically exclude this code from production builds.
      return makeUntrackedSearchParamsWithDevWarnings(
        underlyingSearchParams,
        workStore,
        requestStore,
        isRuntimePrefetchable
      )
    } else if (requestStore.asyncApiPromises) {
      if (requestStore.validationSamples) {
        const { createExhaustiveSearchParamsProxy } =
          require('../app-render/instant-validation/instant-samples') as typeof import('../app-render/instant-validation/instant-samples')
        const declaredKeys = new Set(
          Object.keys(requestStore.validationSamples.searchParams ?? {})
        )
        underlyingSearchParams = createExhaustiveSearchParamsProxy(
          underlyingSearchParams,
          declaredKeys,
          workStore.route
        )
      }

      return (
        isRuntimePrefetchable
          ? requestStore.asyncApiPromises.earlySharedSearchParamsParent
          : requestStore.asyncApiPromises.sharedSearchParamsParent
      ).then(() => underlyingSearchParams)
    } else {
      return makeUntrackedSearchParams(underlyingSearchParams)
    }
  }
}

interface CacheLifetime {}
const CachedSearchParams = new WeakMap<CacheLifetime, Promise<SearchParams>>()

const CachedSearchParamsForUseCache = new WeakMap<
  CacheLifetime,
  Promise<SearchParams>
>()

function makeHangingSearchParams(
  workStore: WorkStore,
  prerenderStore: PrerenderStoreModern
): Promise<SearchParams> {
  const cachedSearchParams = CachedSearchParams.get(prerenderStore)
  if (cachedSearchParams) {
    return cachedSearchParams
  }

  const promise = makeHangingPromise<SearchParams>(
    prerenderStore.renderSignal,
    workStore.route,
    '`searchParams`'
  )

  const proxiedPromise = new Proxy(promise, {
    get(target, prop, receiver) {
      if (Object.hasOwn(promise, prop)) {
        // The promise has this property directly. we must return it.
        // We know it isn't a dynamic access because it can only be something
        // that was previously written to the promise and thus not an underlying searchParam value
        return ReflectAdapter.get(target, prop, receiver)
      }

      switch (prop) {
        case 'then': {
          const expression =
            '`await searchParams`, `searchParams.then`, or similar'
          annotateDynamicAccess(expression, prerenderStore)
          return ReflectAdapter.get(target, prop, receiver)
        }
        case 'status': {
          const expression =
            '`use(searchParams)`, `searchParams.status`, or similar'
          annotateDynamicAccess(expression, prerenderStore)
          return ReflectAdapter.get(target, prop, receiver)
        }

        default: {
          return ReflectAdapter.get(target, prop, receiver)
        }
      }
    },
  })

  CachedSearchParams.set(prerenderStore, proxiedPromise)
  return proxiedPromise
}

function makeErroringSearchParams(
  workStore: WorkStore,
  prerenderStore: PrerenderStoreLegacy | PrerenderStorePPR
): Promise<SearchParams> {
  const cachedSearchParams = CachedSearchParams.get(workStore)
  if (cachedSearchParams) {
    return cachedSearchParams
  }

  const underlyingSearchParams = {}
  // For search params we don't construct a ReactPromise because we want to interrupt
  // rendering on any property access that was not set from outside and so we only want
  // to have properties like value and status if React sets them.
  const promise = Promise.resolve(underlyingSearchParams)

  const proxiedPromise = new Proxy(promise, {
    get(target, prop, receiver) {
      if (Object.hasOwn(promise, prop)) {
        // The promise has this property directly. we must return it.
        // We know it isn't a dynamic access because it can only be something
        // that was previously written to the promise and thus not an underlying searchParam value
        return ReflectAdapter.get(target, prop, receiver)
      }

      if (typeof prop === 'string' && prop === 'then') {
        const expression =
          '`await searchParams`, `searchParams.then`, or similar'
        if (workStore.dynamicShouldError) {
          throwWithStaticGenerationBailoutErrorWithDynamicError(
            workStore.route,
            expression
          )
        } else if (prerenderStore.type === 'prerender-ppr') {
          // PPR Prerender (no cacheComponents)
          postponeWithTracking(
            workStore.route,
            expression,
            prerenderStore.dynamicTracking
          )
        } else {
          // Legacy Prerender
          throwToInterruptStaticGeneration(
            expression,
            workStore,
            prerenderStore
          )
        }
      }
      return ReflectAdapter.get(target, prop, receiver)
    },
  })

  CachedSearchParams.set(workStore, proxiedPromise)
  return proxiedPromise
}

/**
 * This is a variation of `makeErroringSearchParams` that always throws an
 * error on access, because accessing searchParams inside of `"use cache"` is
 * not allowed.
 */
export function makeErroringSearchParamsForUseCache(): Promise<SearchParams> {
  const workStore = workAsyncStorage.getStore()
  if (!workStore) {
    throw new InvariantError('Expected workStore to be initialized')
  }
  const cachedSearchParams = CachedSearchParamsForUseCache.get(workStore)
  if (cachedSearchParams) {
    return cachedSearchParams
  }

  const promise = Promise.resolve({})

  const proxiedPromise = new Proxy(promise, {
    get: function get(target, prop, receiver) {
      if (Object.hasOwn(promise, prop)) {
        // The promise has this property directly. we must return it. We know it
        // isn't a dynamic access because it can only be something that was
        // previously written to the promise and thus not an underlying
        // searchParam value
        return ReflectAdapter.get(target, prop, receiver)
      }

      if (
        typeof prop === 'string' &&
        (prop === 'then' || !wellKnownProperties.has(prop))
      ) {
        throwForSearchParamsAccessInUseCache(workStore, get)
      }

      return ReflectAdapter.get(target, prop, receiver)
    },
  })

  CachedSearchParamsForUseCache.set(workStore, proxiedPromise)
  return proxiedPromise
}

function makeUntrackedSearchParams(
  underlyingSearchParams: SearchParams
): Promise<SearchParams> {
  const cachedSearchParams = CachedSearchParams.get(underlyingSearchParams)
  if (cachedSearchParams) {
    return cachedSearchParams
  }

  const promise = Promise.resolve(underlyingSearchParams)
  CachedSearchParams.set(underlyingSearchParams, promise)

  return promise
}

function makeUntrackedSearchParamsWithDevWarnings(
  underlyingSearchParams: SearchParams,
  workStore: WorkStore,
  requestStore: RequestStore,
  isRuntimePrefetchable: boolean
): Promise<SearchParams> {
  if (requestStore.asyncApiPromises) {
    // Do not cache the resulting promise. If we do, we'll only show the first "awaited at"
    // across all segments that receive searchParams.
    return makeUntrackedSearchParamsWithDevWarningsImpl(
      underlyingSearchParams,
      workStore,
      requestStore,
      isRuntimePrefetchable
    )
  } else {
    const cachedSearchParams = CachedSearchParams.get(underlyingSearchParams)
    if (cachedSearchParams) {
      return cachedSearchParams
    }
    const promise = makeUntrackedSearchParamsWithDevWarningsImpl(
      underlyingSearchParams,
      workStore,
      requestStore,
      isRuntimePrefetchable
    )
    CachedSearchParams.set(requestStore, promise)
    return promise
  }
}

function makeUntrackedSearchParamsWithDevWarningsImpl(
  underlyingSearchParams: SearchParams,
  workStore: WorkStore,
  requestStore: RequestStore,
  isRuntimePrefetchable: boolean
): Promise<SearchParams> {
  const promiseInitialized = { current: false }
  const proxiedUnderlying = instrumentSearchParamsObjectWithDevWarnings(
    underlyingSearchParams,
    workStore,
    promiseInitialized
  )

  let promise: Promise<SearchParams>
  if (requestStore.asyncApiPromises) {
    // We wrap each instance of searchParams in a `new Promise()`.
    // This is important when all awaits are in third party which would otherwise
    // track all the way to the internal params.
    const sharedSearchParamsParent = isRuntimePrefetchable
      ? requestStore.asyncApiPromises.earlySharedSearchParamsParent
      : requestStore.asyncApiPromises.sharedSearchParamsParent
    promise = new Promise((resolve, reject) => {
      sharedSearchParamsParent.then(() => resolve(proxiedUnderlying), reject)
    })
    // @ts-expect-error
    promise.displayName = 'searchParams'
  } else {
    promise = makeDevtoolsIOAwarePromise(
      proxiedUnderlying,
      requestStore,
      RenderStage.Runtime
    )
  }
  promise.then(
    () => {
      promiseInitialized.current = true
    },
    // If we're in staged rendering, this promise will reject if the render
    // is aborted before it can reach the runtime stage.
    // In that case, we have to prevent an unhandled rejection from the promise
    // created by this `.then()` call.
    // This does not affect the `promiseInitialized` logic above,
    // because `proxiedUnderlying` will not be used to resolve the promise,
    // so there's no risk of any of its properties being accessed and triggering
    // an undesireable warning.
    ignoreReject
  )

  return instrumentSearchParamsPromiseWithDevWarnings(
    underlyingSearchParams,
    promise,
    workStore
  )
}

function ignoreReject() {}

function instrumentSearchParamsObjectWithDevWarnings(
  underlyingSearchParams: SearchParams,
  workStore: WorkStore,
  promiseInitialized: { current: boolean }
) {
  // We have an unfortunate sequence of events that requires this initialization logic. We want to instrument the underlying
  // searchParams object to detect if you are accessing values in dev. This is used for warnings and for things like the static prerender
  // indicator. However when we pass this proxy to our Promise.resolve() below the VM checks if the resolved value is a promise by looking
  // at the `.then` property. To our dynamic tracking logic this is indistinguishable from a `then` searchParam and so we would normally trigger
  // dynamic tracking. However we know that this .then is not real dynamic access, it's just how thenables resolve in sequence. So we introduce
  // this initialization concept so we omit the dynamic check until after we've constructed our resolved promise.
  return new Proxy(underlyingSearchParams, {
    get(target, prop, receiver) {
      if (typeof prop === 'string' && promiseInitialized.current) {
        if (workStore.dynamicShouldError) {
          const expression = describeStringPropertyAccess('searchParams', prop)
          throwWithStaticGenerationBailoutErrorWithDynamicError(
            workStore.route,
            expression
          )
        }
      }
      return ReflectAdapter.get(target, prop, receiver)
    },
    has(target, prop) {
      if (typeof prop === 'string') {
        if (workStore.dynamicShouldError) {
          const expression = describeHasCheckingStringProperty(
            'searchParams',
            prop
          )
          throwWithStaticGenerationBailoutErrorWithDynamicError(
            workStore.route,
            expression
          )
        }
      }
      return Reflect.has(target, prop)
    },
    ownKeys(target) {
      if (workStore.dynamicShouldError) {
        const expression =
          '`{...searchParams}`, `Object.keys(searchParams)`, or similar'
        throwWithStaticGenerationBailoutErrorWithDynamicError(
          workStore.route,
          expression
        )
      }
      return Reflect.ownKeys(target)
    },
  })
}

function instrumentSearchParamsPromiseWithDevWarnings(
  underlyingSearchParams: SearchParams,
  promise: Promise<SearchParams>,
  workStore: WorkStore
) {
  // Track which properties we should warn for.
  const proxiedProperties = new Set<string>()

  Object.keys(underlyingSearchParams).forEach((prop) => {
    if (wellKnownProperties.has(prop)) {
      // These properties cannot be shadowed because they need to be the
      // true underlying value for Promises to work correctly at runtime
    } else {
      proxiedProperties.add(prop)
    }
  })

  return new Proxy(promise, {
    get(target, prop, receiver) {
      if (prop === 'then' && workStore.dynamicShouldError) {
        const expression = '`searchParams.then`'
        throwWithStaticGenerationBailoutErrorWithDynamicError(
          workStore.route,
          expression
        )
      }
      if (typeof prop === 'string') {
        if (
          !wellKnownProperties.has(prop) &&
          (proxiedProperties.has(prop) ||
            // We are accessing a property that doesn't exist on the promise nor
            // the underlying searchParams.
            Reflect.has(target, prop) === false)
        ) {
          const expression = describeStringPropertyAccess('searchParams', prop)
          warnForSyncAccess(workStore.route, expression)
        }
      }
      return ReflectAdapter.get(target, prop, receiver)
    },
    set(target, prop, value, receiver) {
      if (typeof prop === 'string') {
        proxiedProperties.delete(prop)
      }
      return Reflect.set(target, prop, value, receiver)
    },
    has(target, prop) {
      if (typeof prop === 'string') {
        if (
          !wellKnownProperties.has(prop) &&
          (proxiedProperties.has(prop) ||
            // We are accessing a property that doesn't exist on the promise nor
            // the underlying searchParams.
            Reflect.has(target, prop) === false)
        ) {
          const expression = describeHasCheckingStringProperty(
            'searchParams',
            prop
          )
          warnForSyncAccess(workStore.route, expression)
        }
      }
      return Reflect.has(target, prop)
    },
    ownKeys(target) {
      const expression = '`Object.keys(searchParams)` or similar'
      warnForSyncAccess(workStore.route, expression)
      return Reflect.ownKeys(target)
    },
  })
}

const warnForSyncAccess = createDedupedByCallsiteServerErrorLoggerDev(
  createSearchAccessError
)

function createSearchAccessError(
  route: string | undefined,
  expression: string
) {
  const prefix = route ? `Route "${route}" ` : 'This route '
  return new Error(
    `${prefix}used ${expression}. ` +
      `\`searchParams\` is a Promise and must be unwrapped with \`await\` or \`React.use()\` before accessing its properties. ` +
      `Learn more: https://nextjs.org/docs/messages/sync-dynamic-apis`
  )
}

function createClientSearchParamsInValidation(
  underlyingSearchParams: SearchParams,
  workStore: WorkStore,
  workUnitStore: ValidationStoreClient
) {
  const { createExhaustiveSearchParamsProxy } =
    require('../app-render/instant-validation/instant-samples') as typeof import('../app-render/instant-validation/instant-samples')
  const declaredKeys = new Set(
    Object.keys(workUnitStore.validationSamples?.searchParams ?? {})
  )
  underlyingSearchParams = createExhaustiveSearchParamsProxy(
    underlyingSearchParams,
    declaredKeys,
    workStore.route
  )
  return Promise.resolve(underlyingSearchParams)
}
Quest for Codev2.0.0
/
SIGN IN