Repository: vercel/next.js
Pull request: Extract getDynamicParam to a shared module

I'm in the midst of a refactor to move dynamic route param parsing to the client. This way client components can extract route params from the browser URL without needing to make a server request. For example, if a page is completely rendered on the client, we should be able to change the URL without a request, even if the page accesses params.

In this PR, I've extracted the logic for parsing dynamic params into a shared module. It's not actually loaded on the client yet, but I wanted to move this into a separate file as its own step to make sure it works on its own.

We need to be careful not to make this logic any more complex than it needs to be. Ideally it would be little more than a regex. For most params, that's the case, but alas there's already some unfortunate special case handling for catchall routes.

Changed files:
filename: packages/next/src/server/app-render/app-render.tsx
patch: @@ -80,10 +80,7 @@ import {
   isUserLandError,
   getDigestForWellKnownError,
 } from './create-error-handler'
-import {
-  getShortDynamicParamType,
-  dynamicParamTypes,
-} from './get-short-dynamic-param-type'
+import { dynamicParamTypes } from './get-short-dynamic-param-type'
 import { getSegmentParam } from './get-segment-param'
 import { getScriptNonceFromHeader } from './get-script-nonce-from-header'
 import { parseAndValidateFlightRouterState } from './parse-and-validate-flight-router-state'
@@ -143,7 +140,6 @@ import {
 } from '../client-component-renderer-logger'
 import { createServerModuleMap } from './action-utils'
 import { isNodeNextRequest } from '../base-http/helpers'
-import { parseParameter } from '../../shared/lib/router/utils/route-regex'
 import { parseRelativeUrl } from '../../shared/lib/router/utils/parse-relative-url'
 import AppRouter from '../../client/components/app-router'
 import type { ServerComponentsHmrCache } from '../response-cache'
@@ -196,16 +192,19 @@ import { isReactLargeShellError } from './react-large-shell-error'
 import type { GlobalErrorComponent } from '../../client/components/builtin/global-error'
 import { normalizeConventionFilePath } from './segment-explorer-path'
 import { getRequestMeta } from '../request-meta'
+import { getDynamicParam } from '../../shared/lib/router/utils/get-dynamic-param'
 
 export type GetDynamicParamFromSegment = (
   // [slug] / [[slug]] / [...slug]
   segment: string
-) => {
+) => DynamicParam | null
+
+export type DynamicParam = {
   param: string
   value: string | string[] | null
   treeSegment: Segment
   type: DynamicParamTypesShort
-} | null
+}
 
 export type GenerateFlight = typeof generateDynamicFlightRenderResult
 
@@ -365,70 +364,15 @@ function makeGetDynamicParamFromSegment(
     if (!segmentParam) {
       return null
     }
-
-    const key = segmentParam.param
-
-    let value = params[key]
-
-    if (fallbackRouteParams && fallbackRouteParams.has(segmentParam.param)) {
-      value = fallbackRouteParams.get(segmentParam.param)
-    } else if (Array.isArray(value)) {
-      value = value.map((i) => encodeURIComponent(i))
-    } else if (typeof value === 'string') {
-      value = encodeURIComponent(value)
-    }
-
-    if (!value) {
-      const isCatchall = segmentParam.type === 'catchall'
-      const isOptionalCatchall = segmentParam.type === 'optional-catchall'
-
-      if (isCatchall || isOptionalCatchall) {
-        const dynamicParamType = dynamicParamTypes[segmentParam.type]
-        // handle the case where an optional catchall does not have a value,
-        // e.g. `/dashboard/[[...slug]]` when requesting `/dashboard`
-        if (isOptionalCatchall) {
-          return {
-            param: key,
-            value: null,
-            type: dynamicParamType,
-            treeSegment: [key, '', dynamicParamType],
-          }
-        }
-
-        // handle the case where a catchall or optional catchall does not have a value,
-        // e.g. `/foo/bar/hello` and `@slot/[...catchall]` or `@slot/[[...catchall]]` is matched
-        value = pagePath
-          .split('/')
-          // remove the first empty string
-          .slice(1)
-          // replace any dynamic params with the actual values
-          .flatMap((pathSegment) => {
-            const param = parseParameter(pathSegment)
-            // if the segment matches a param, return the param value
-            // otherwise, it's a static segment, so just return that
-            return params[param.key] ?? param.key
-          })
-
-        return {
-          param: key,
-          value,
-          type: dynamicParamType,
-          // This value always has to be a string.
-          treeSegment: [key, value.join('/'), dynamicParamType],
-        }
-      }
-    }
-
-    const type = getShortDynamicParamType(segmentParam.type)
-
-    return {
-      param: key,
-      // The value that is passed to user code.
-      value: value,
-      // The value that is rendered in the router tree.
-      treeSegment: [key, Array.isArray(value) ? value.join('/') : value, type],
-      type: type,
-    }
+    const segmentKey = segmentParam.param
+    const dynamicParamType = dynamicParamTypes[segmentParam.type]
+    return getDynamicParam(
+      params,
+      segmentKey,
+      dynamicParamType,
+      pagePath,
+      fallbackRouteParams
+    )
   }
 }
 

filename: packages/next/src/shared/lib/router/utils/get-dynamic-param.ts
patch: @@ -0,0 +1,141 @@
+import type { DynamicParam } from '../../../../server/app-render/app-render'
+import type { DynamicParamTypesShort } from '../../../../server/app-render/types'
+import type { FallbackRouteParams } from '../../../../server/request/fallback-params'
+
+/**
+ *
+ * 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(
+  params: { [key: string]: any },
+  segmentKey: string,
+  dynamicParamType: DynamicParamTypesShort,
+  pagePath: string,
+  fallbackRouteParams: FallbackRouteParams | null
+): DynamicParam {
+  let value = params[segmentKey]
+
+  if (fallbackRouteParams && fallbackRouteParams.has(segmentKey)) {
+    value = fallbackRouteParams.get(segmentKey)
+  } else if (Array.isArray(value)) {
+    value = value.map((i) => encodeURIComponent(i))
+  } else if (typeof value === 'string') {
+    value = encodeURIComponent(value)
+  }
+
+  if (!value) {
+    const isCatchall = dynamicParamType === 'c'
+    const isOptionalCatchall = dynamicParamType === 'oc'
+
+    if (isCatchall || isOptionalCatchall) {
+      // handle the case where an optional catchall does not have a value,
+      // e.g. `/dashboard/[[...slug]]` when requesting `/dashboard`
+      if (isOptionalCatchall) {
+        return {
+          param: segmentKey,
+          value: null,
+          type: dynamicParamType,
+          treeSegment: [segmentKey, '', dynamicParamType],
+        }
+      }
+
+      // handle the case where a catchall or optional catchall does not have a value,
+      // e.g. `/foo/bar/hello` and `@slot/[...catchall]` or `@slot/[[...catchall]]` is matched
+      value = pagePath
+        .split('/')
+        // remove the first empty string
+        .slice(1)
+        // replace any dynamic params with the actual values
+        .flatMap((pathSegment) => {
+          const param = parseParameter(pathSegment)
+          // if the segment matches a param, return the param value
+          // otherwise, it's a static segment, so just return that
+          return params[param.key] ?? param.key
+        })
+
+      return {
+        param: segmentKey,
+        value,
+        type: dynamicParamType,
+        // This value always has to be a string.
+        treeSegment: [segmentKey, value.join('/'), dynamicParamType],
+      }
+    }
+  }
+
+  return {
+    param: segmentKey,
+    // The value that is passed to user code.
+    value: value,
+    // The value that is rendered in the router tree.
+    treeSegment: [
+      segmentKey,
+      Array.isArray(value) ? value.join('/') : value,
+      dynamicParamType,
+    ],
+    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 }
+}

filename: packages/next/src/shared/lib/router/utils/route-regex.test.ts
patch: @@ -1,5 +1,5 @@
 import { getNamedRouteRegex } from './route-regex'
-import { parseParameter } from './route-regex'
+import { parseParameter } from './get-dynamic-param'
 
 describe('getNamedRouteRegex', () => {
   it('should handle interception markers adjacent to dynamic path segments', () => {

filename: packages/next/src/shared/lib/router/utils/route-regex.ts
patch: @@ -5,6 +5,7 @@ import {
 import { INTERCEPTION_ROUTE_MARKERS } from './interception-routes'
 import { escapeStringRegexp } from '../../escape-regexp'
 import { removeTrailingSlash } from './remove-trailing-slash'
+import { PARAMETER_PATTERN, parseMatchedParameter } from './get-dynamic-param'
 
 export interface Group {
   pos: number
@@ -77,62 +78,6 @@ type GetRouteRegexOptions = {
   excludeOptionalTrailingSlash?: boolean
 }
 
-/**
- * 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
- */
-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.
- */
-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 }
-}
-
 function getParametrizedRoute(
   route: string,
   includeSuffix: boolean,