next.js/packages/next/src/shared/lib/router/utils/get-dynamic-param.ts
get-dynamic-param.ts221 lines7.5 KB
import type { DynamicParam } from '../../../../server/app-render/app-render'
import type { LoaderTree } from '../../../../server/lib/app-dir-module'
import type { OpaqueFallbackRouteParams } from '../../../../server/request/fallback-params'
import type { Params } from '../../../../server/request/params'
import type { DynamicParamTypesShort } from '../../app-router-types'
import { InvariantError } from '../../invariant-error'
import { parseLoaderTree } from './parse-loader-tree'
import { parseNormalizedAppRoute, parseAppRouteSegment } from '../routes/app'
import { resolveParamValue } from './resolve-param-value'

/**
 * Gets the value of a param from the params object. This correctly handles the
 * case where the param is a fallback route param and encodes the resulting
 * value.
 *
 * @param interpolatedParams - The params object.
 * @param segmentKey - The key of the segment.
 * @param fallbackRouteParams - The fallback route params.
 * @returns The value of the param.
 */
function getParamValue(
  interpolatedParams: Params,
  segmentKey: string,
  fallbackRouteParams: OpaqueFallbackRouteParams | null
) {
  let value = interpolatedParams[segmentKey]

  if (fallbackRouteParams?.has(segmentKey)) {
    // We know that the fallback route params has the segment key because we
    // checked that above.
    const [searchValue] = fallbackRouteParams.get(segmentKey)!
    value = searchValue
  } else if (Array.isArray(value)) {
    value = value.map((i) => encodeURIComponent(i))
  } else if (typeof value === 'string') {
    value = encodeURIComponent(value)
  }

  return value
}

export function interpolateParallelRouteParams(
  loaderTree: LoaderTree,
  params: Params,
  pagePath: string,
  fallbackRouteParams: OpaqueFallbackRouteParams | null
): Params {
  const interpolated = structuredClone(params)

  // Stack-based traversal with depth tracking
  const stack: Array<{ tree: LoaderTree; depth: number }> = [
    { tree: loaderTree, depth: 0 },
  ]

  // Parse the route from the provided page path.
  const route = parseNormalizedAppRoute(pagePath)

  while (stack.length > 0) {
    const { tree, depth } = stack.pop()!
    const { segment, parallelRoutes } = parseLoaderTree(tree)

    const appSegment = parseAppRouteSegment(segment)

    if (
      appSegment?.type === 'dynamic' &&
      !interpolated.hasOwnProperty(appSegment.param.paramName) &&
      // If the param is in the fallback route params, we don't need to
      // interpolate it because it's already marked as being unknown.
      !fallbackRouteParams?.has(appSegment.param.paramName)
    ) {
      const { paramName, paramType } = appSegment.param

      const paramValue = resolveParamValue(
        paramName,
        paramType,
        depth,
        route,
        interpolated
      )

      if (paramValue !== undefined) {
        interpolated[paramName] = paramValue
      } else if (paramType !== 'optional-catchall') {
        throw new InvariantError(
          `Could not resolve param value for segment: ${paramName}`
        )
      }
    }

    // Calculate next depth - increment if this is not a route group and not empty
    let nextDepth = depth
    if (
      appSegment &&
      appSegment.type !== 'route-group' &&
      appSegment.type !== 'parallel-route'
    ) {
      nextDepth++
    }

    // Add all parallel routes to the stack for processing
    for (const parallelRoute of Object.values(parallelRoutes)) {
      stack.push({ tree: parallelRoute, depth: nextDepth })
    }
  }

  return interpolated
}

/**
 *
 * Shared logic on client and server for creating a dynamic param value.
 *
 * This code needs to be shared with the client so it can extract dynamic route
 * params from the URL without a server request.
 *
 * Because everything in this module is sent to the client, we should aim to
 * keep this code as simple as possible. The special case handling for catchall
 * and optional is, alas, unfortunate.
 */
export function getDynamicParam(
  interpolatedParams: Params,
  segmentKey: string,
  dynamicParamType: DynamicParamTypesShort,
  fallbackRouteParams: OpaqueFallbackRouteParams | null,
  staticSiblings: readonly string[] | null
): DynamicParam {
  let value: string | string[] | undefined = getParamValue(
    interpolatedParams,
    segmentKey,
    fallbackRouteParams
  )

  // handle the case where an optional catchall does not have a value,
  // e.g. `/dashboard/[[...slug]]` when requesting `/dashboard`
  if (!value || value.length === 0) {
    if (dynamicParamType === 'oc') {
      return {
        param: segmentKey,
        value: null,
        type: dynamicParamType,
        treeSegment: [segmentKey, '', dynamicParamType, staticSiblings],
      }
    }

    throw new InvariantError(
      `Missing value for segment key: "${segmentKey}" with dynamic param type: ${dynamicParamType}`
    )
  }

  const paramCacheKey = Array.isArray(value) ? value.join('/') : value

  return {
    param: segmentKey,
    // The value that is passed to user code.
    value,
    // The value that is rendered in the router tree.
    // TODO: If the number of static siblings exceeds some threshold (e.g.,
    // dozens or hundreds), consider sending a Bloom filter instead of the full
    // array to reduce payload size. The client would then use the Bloom filter
    // to check membership with a small false positive rate.
    treeSegment: [segmentKey, paramCacheKey, dynamicParamType, staticSiblings],
    type: dynamicParamType,
  }
}

/**
 * Regular expression pattern used to match route parameters.
 * Matches both single parameters and parameter groups.
 * Examples:
 *   - `[[...slug]]` matches parameter group with key 'slug', repeat: true, optional: true
 *   - `[...slug]` matches parameter group with key 'slug', repeat: true, optional: false
 *   - `[[foo]]` matches parameter with key 'foo', repeat: false, optional: true
 *   - `[bar]` matches parameter with key 'bar', repeat: false, optional: false
 */
export const PARAMETER_PATTERN = /^([^[]*)\[((?:\[[^\]]*\])|[^\]]+)\](.*)$/

/**
 * Parses a given parameter from a route to a data structure that can be used
 * to generate the parametrized route.
 * Examples:
 *   - `[[...slug]]` -> `{ key: 'slug', repeat: true, optional: true }`
 *   - `[...slug]` -> `{ key: 'slug', repeat: true, optional: false }`
 *   - `[[foo]]` -> `{ key: 'foo', repeat: false, optional: true }`
 *   - `[bar]` -> `{ key: 'bar', repeat: false, optional: false }`
 *   - `fizz` -> `{ key: 'fizz', repeat: false, optional: false }`
 * @param param - The parameter to parse.
 * @returns The parsed parameter as a data structure.
 */
export function parseParameter(param: string) {
  const match = param.match(PARAMETER_PATTERN)

  if (!match) {
    return parseMatchedParameter(param)
  }

  return parseMatchedParameter(match[2])
}

/**
 * Parses a matched parameter from the PARAMETER_PATTERN regex to a data structure that can be used
 * to generate the parametrized route.
 * Examples:
 *   - `[...slug]` -> `{ key: 'slug', repeat: true, optional: true }`
 *   - `...slug` -> `{ key: 'slug', repeat: true, optional: false }`
 *   - `[foo]` -> `{ key: 'foo', repeat: false, optional: true }`
 *   - `bar` -> `{ key: 'bar', repeat: false, optional: false }`
 * @param param - The matched parameter to parse.
 * @returns The parsed parameter as a data structure.
 */
export function parseMatchedParameter(param: string) {
  const optional = param.startsWith('[') && param.endsWith(']')
  if (optional) {
    param = param.slice(1, -1)
  }
  const repeat = param.startsWith('...')
  if (repeat) {
    param = param.slice(3)
  }
  return { key: param, repeat, optional }
}
Quest for Codev2.0.0
/
SIGN IN