Repository: vercel/next.js
Pull request: Replace getDynamicParamFromSegment closure with interpolatedParams in metadata

Stacked on #90217

Metadata resolution only needs param names and values to pass to generateMetadata. It doesn't need treeSegment, staticSiblings, or the DynamicParam type that getDynamicParamFromSegment produces. Replace the closure dependency with interpolatedParams (a plain Params object) and use getSegmentParam to parse segment names inline. This replaces a closure over request-scoped state with a simple data value, removing another dependency that blocked making metadata components standalone.

Changed files:
filename: packages/next/src/lib/metadata/metadata.tsx
patch: @@ -1,6 +1,6 @@
 import React, { Suspense, cache } from 'react'
 import type { ParsedUrlQuery } from 'querystring'
-import type { GetDynamicParamFromSegment } from '../../server/app-render/app-render'
+import type { Params } from '../../server/request/params'
 import type { LoaderTree } from '../../server/lib/app-dir-module'
 import type { SearchParams } from '../../server/request/search-params'
 import {
@@ -38,15 +38,15 @@ export function createMetadataComponents({
   pathname,
   parsedQuery,
   metadataContext,
-  getDynamicParamFromSegment,
+  interpolatedParams,
   errorType,
   serveStreamingMetadata,
 }: {
   tree: LoaderTree
   pathname: string
   parsedQuery: SearchParams
   metadataContext: MetadataContext
-  getDynamicParamFromSegment: GetDynamicParamFromSegment
+  interpolatedParams: Params
   errorType?: MetadataErrorType | 'redirect'
   serveStreamingMetadata: boolean
 }): {
@@ -61,7 +61,7 @@ export function createMetadataComponents({
     const tags = await getResolvedViewport(
       tree,
       searchParams,
-      getDynamicParamFromSegment,
+      interpolatedParams,
       errorType
     ).catch((viewportErr) => {
       // When Legacy PPR is enabled viewport can reject with a Postpone type
@@ -74,7 +74,7 @@ export function createMetadataComponents({
         return getNotFoundViewport(
           tree,
           searchParams,
-          getDynamicParamFromSegment
+          interpolatedParams
         ).catch(() => null)
       }
       // We're going to throw the error from the metadata outlet so we just render null here instead
@@ -98,7 +98,7 @@ export function createMetadataComponents({
       tree,
       pathnameForMetadata,
       searchParams,
-      getDynamicParamFromSegment,
+      interpolatedParams,
       metadataContext,
       errorType
     ).catch((metadataErr) => {
@@ -113,7 +113,7 @@ export function createMetadataComponents({
           tree,
           pathnameForMetadata,
           searchParams,
-          getDynamicParamFromSegment,
+          interpolatedParams,
           metadataContext
         ).catch(() => null)
       }
@@ -153,16 +153,11 @@ export function createMetadataComponents({
         tree,
         pathnameForMetadata,
         searchParams,
-        getDynamicParamFromSegment,
+        interpolatedParams,
         metadataContext,
         errorType
       ),
-      getResolvedViewport(
-        tree,
-        searchParams,
-        getDynamicParamFromSegment,
-        errorType
-      ),
+      getResolvedViewport(tree, searchParams, interpolatedParams, errorType),
     ]).then(() => null)
 
     // TODO: We shouldn't change what we render based on whether we are streaming or not.
@@ -191,7 +186,7 @@ async function getResolvedMetadataImpl(
   tree: LoaderTree,
   pathname: Promise<string>,
   searchParams: Promise<ParsedUrlQuery>,
-  getDynamicParamFromSegment: GetDynamicParamFromSegment,
+  interpolatedParams: Params,
   metadataContext: MetadataContext,
   errorType?: MetadataErrorType | 'redirect'
 ): Promise<React.ReactNode> {
@@ -200,7 +195,7 @@ async function getResolvedMetadataImpl(
     tree,
     pathname,
     searchParams,
-    getDynamicParamFromSegment,
+    interpolatedParams,
     metadataContext,
     errorConvention
   )
@@ -211,15 +206,15 @@ async function getNotFoundMetadataImpl(
   tree: LoaderTree,
   pathname: Promise<string>,
   searchParams: Promise<ParsedUrlQuery>,
-  getDynamicParamFromSegment: GetDynamicParamFromSegment,
+  interpolatedParams: Params,
   metadataContext: MetadataContext
 ): Promise<React.ReactNode> {
   const notFoundErrorConvention = 'not-found'
   return renderMetadata(
     tree,
     pathname,
     searchParams,
-    getDynamicParamFromSegment,
+    interpolatedParams,
     metadataContext,
     notFoundErrorConvention
   )
@@ -229,29 +224,24 @@ const getResolvedViewport = cache(getResolvedViewportImpl)
 async function getResolvedViewportImpl(
   tree: LoaderTree,
   searchParams: Promise<ParsedUrlQuery>,
-  getDynamicParamFromSegment: GetDynamicParamFromSegment,
+  interpolatedParams: Params,
   errorType?: MetadataErrorType | 'redirect'
 ): Promise<React.ReactNode> {
   const errorConvention = errorType === 'redirect' ? undefined : errorType
-  return renderViewport(
-    tree,
-    searchParams,
-    getDynamicParamFromSegment,
-    errorConvention
-  )
+  return renderViewport(tree, searchParams, interpolatedParams, errorConvention)
 }
 
 const getNotFoundViewport = cache(getNotFoundViewportImpl)
 async function getNotFoundViewportImpl(
   tree: LoaderTree,
   searchParams: Promise<ParsedUrlQuery>,
-  getDynamicParamFromSegment: GetDynamicParamFromSegment
+  interpolatedParams: Params
 ): Promise<React.ReactNode> {
   const notFoundErrorConvention = 'not-found'
   return renderViewport(
     tree,
     searchParams,
-    getDynamicParamFromSegment,
+    interpolatedParams,
     notFoundErrorConvention
   )
 }
@@ -260,7 +250,7 @@ async function renderMetadata(
   tree: LoaderTree,
   pathname: Promise<string>,
   searchParams: Promise<ParsedUrlQuery>,
-  getDynamicParamFromSegment: GetDynamicParamFromSegment,
+  interpolatedParams: Params,
   metadataContext: MetadataContext,
   errorConvention?: MetadataErrorType
 ) {
@@ -269,7 +259,7 @@ async function renderMetadata(
     pathname,
     searchParams,
     errorConvention,
-    getDynamicParamFromSegment,
+    interpolatedParams,
     metadataContext
   )
   return <>{createMetadataElements(resolvedMetadata)}</>
@@ -278,14 +268,14 @@ async function renderMetadata(
 async function renderViewport(
   tree: LoaderTree,
   searchParams: Promise<ParsedUrlQuery>,
-  getDynamicParamFromSegment: GetDynamicParamFromSegment,
+  interpolatedParams: Params,
   errorConvention?: MetadataErrorType
 ) {
   const resolvedViewport = await resolveViewport(
     tree,
     searchParams,
     errorConvention,
-    getDynamicParamFromSegment
+    interpolatedParams
   )
   return <>{createViewportElements(resolvedViewport)}</>
 }

filename: packages/next/src/lib/metadata/resolve-metadata.ts
patch: @@ -8,7 +8,7 @@ import type {
   WithStringifiedURLs,
 } from './types/metadata-interface'
 import type { MetadataImageModule } from '../../build/webpack/loaders/metadata/types'
-import type { GetDynamicParamFromSegment } from '../../server/app-render/app-render'
+import { getSegmentParam } from '../../shared/lib/router/utils/get-segment-param'
 import type { Twitter } from './types/twitter-types'
 import type { OpenGraph } from './types/opengraph-types'
 import type { AppDirModules } from '../../build/webpack/loaders/next-app-loader'
@@ -709,7 +709,7 @@ const resolveMetadataItems = cache(async function (
   tree: LoaderTree,
   searchParams: Promise<ParsedUrlQuery>,
   errorConvention: MetadataErrorType | undefined,
-  getDynamicParamFromSegment: GetDynamicParamFromSegment
+  interpolatedParams: Params
 ) {
   const parentParams = {}
   const metadataItems: MetadataItems = []
@@ -723,7 +723,7 @@ const resolveMetadataItems = cache(async function (
     searchParams,
     errorConvention,
     errorMetadataItem,
-    getDynamicParamFromSegment
+    interpolatedParams
   )
 })
 
@@ -736,23 +736,23 @@ async function resolveMetadataItemsImpl(
   searchParams: Promise<ParsedUrlQuery>,
   errorConvention: MetadataErrorType | undefined,
   errorMetadataItem: MetadataItems[number],
-  getDynamicParamFromSegment: GetDynamicParamFromSegment
+  interpolatedParams: Params
 ): Promise<MetadataItems> {
   const [segment, parallelRoutes, { page }] = tree
   const currentTreePrefix =
     treePrefix && treePrefix.length ? [...treePrefix, segment] : [segment]
   const isPage = typeof page !== 'undefined'
 
   // Handle dynamic segment params.
-  const segmentParam = getDynamicParamFromSegment(tree)
-  /**
-   * Create object holding the parent params and current params
-   */
   let currentParams = parentParams
-  if (segmentParam && segmentParam.value !== null) {
-    currentParams = {
-      ...parentParams,
-      [segmentParam.param]: segmentParam.value,
+  const segmentParam = getSegmentParam(segment)
+  if (segmentParam) {
+    const value = interpolatedParams[segmentParam.paramName]
+    if (value !== null && value !== undefined) {
+      currentParams = {
+        ...parentParams,
+        [segmentParam.paramName]: value,
+      }
     }
   }
 
@@ -781,7 +781,7 @@ async function resolveMetadataItemsImpl(
       searchParams,
       errorConvention,
       errorMetadataItem,
-      getDynamicParamFromSegment
+      interpolatedParams
     )
   }
 
@@ -799,7 +799,7 @@ const resolveViewportItems = cache(async function (
   tree: LoaderTree,
   searchParams: Promise<ParsedUrlQuery>,
   errorConvention: MetadataErrorType | undefined,
-  getDynamicParamFromSegment: GetDynamicParamFromSegment
+  interpolatedParams: Params
 ) {
   const parentParams = {}
   const viewportItems: ViewportItems = []
@@ -815,7 +815,7 @@ const resolveViewportItems = cache(async function (
     searchParams,
     errorConvention,
     errorViewportItemRef,
-    getDynamicParamFromSegment
+    interpolatedParams
   )
 })
 
@@ -828,23 +828,23 @@ async function resolveViewportItemsImpl(
   searchParams: Promise<ParsedUrlQuery>,
   errorConvention: MetadataErrorType | undefined,
   errorViewportItemRef: ErrorViewportItemRef,
-  getDynamicParamFromSegment: GetDynamicParamFromSegment
+  interpolatedParams: Params
 ): Promise<ViewportItems> {
   const [segment, parallelRoutes, { page }] = tree
   const currentTreePrefix =
     treePrefix && treePrefix.length ? [...treePrefix, segment] : [segment]
   const isPage = typeof page !== 'undefined'
 
   // Handle dynamic segment params.
-  const segmentParam = getDynamicParamFromSegment(tree)
-  /**
-   * Create object holding the parent params and current params
-   */
   let currentParams = parentParams
-  if (segmentParam && segmentParam.value !== null) {
-    currentParams = {
-      ...parentParams,
-      [segmentParam.param]: segmentParam.value,
+  const segmentParam = getSegmentParam(segment)
+  if (segmentParam) {
+    const value = interpolatedParams[segmentParam.paramName]
+    if (value !== null && value !== undefined) {
+      currentParams = {
+        ...parentParams,
+        [segmentParam.paramName]: value,
+      }
     }
   }
 
@@ -884,7 +884,7 @@ async function resolveViewportItemsImpl(
       searchParams,
       errorConvention,
       errorViewportItemRef,
-      getDynamicParamFromSegment
+      interpolatedParams
     )
   }
 
@@ -1252,14 +1252,14 @@ export async function resolveMetadata(
   pathname: Promise<string>,
   searchParams: Promise<ParsedUrlQuery>,
   errorConvention: MetadataErrorType | undefined,
-  getDynamicParamFromSegment: GetDynamicParamFromSegment,
+  interpolatedParams: Params,
   metadataContext: MetadataContext
 ): Promise<ResolvedMetadata> {
   const metadataItems = await resolveMetadataItems(
     tree,
     searchParams,
     errorConvention,
-    getDynamicParamFromSegment
+    interpolatedParams
   )
   const workStore = workAsyncStorage.getStore()
   if (!workStore) {
@@ -1278,13 +1278,13 @@ export async function resolveViewport(
   tree: LoaderTree,
   searchParams: Promise<ParsedUrlQuery>,
   errorConvention: MetadataErrorType | undefined,
-  getDynamicParamFromSegment: GetDynamicParamFromSegment
+  interpolatedParams: Params
 ): Promise<ResolvedViewport> {
   const viewportItems = await resolveViewportItems(
     tree,
     searchParams,
     errorConvention,
-    getDynamicParamFromSegment
+    interpolatedParams
   )
   return accumulateViewport(viewportItems)
 }

filename: packages/next/src/server/app-render/app-render.tsx
patch: @@ -269,6 +269,7 @@ export type AppRenderContext = {
   renderOpts: RenderOpts
   parsedRequestHeaders: ParsedRequestHeaders
   getDynamicParamFromSegment: GetDynamicParamFromSegment
+  interpolatedParams: Params
   query: NextParsedUrlQuery
   isPrefetch: boolean
   isPossibleServerAction: boolean
@@ -523,7 +524,6 @@ async function generateDynamicRSCPayload(
       createMetadataComponents,
       Fragment,
     },
-    getDynamicParamFromSegment,
     query,
     requestId,
     flightRouterState,
@@ -549,7 +549,7 @@ async function generateDynamicRSCPayload(
       parsedQuery: query,
       pathname: url.pathname,
       metadataContext: createMetadataContext(ctx.renderOpts),
-      getDynamicParamFromSegment,
+      interpolatedParams: ctx.interpolatedParams,
       serveStreamingMetadata,
     })
 
@@ -1442,7 +1442,7 @@ async function getRSCPayload(
     parsedQuery: query,
     pathname: url.pathname,
     metadataContext: createMetadataContext(ctx.renderOpts),
-    getDynamicParamFromSegment,
+    interpolatedParams: ctx.interpolatedParams,
     serveStreamingMetadata,
   })
 
@@ -1562,7 +1562,7 @@ async function getErrorRSCPayload(
     pathname: url.pathname,
     metadataContext: createMetadataContext(ctx.renderOpts),
     errorType,
-    getDynamicParamFromSegment,
+    interpolatedParams: ctx.interpolatedParams,
     serveStreamingMetadata: serveStreamingMetadata,
   })
 
@@ -2020,6 +2020,7 @@ async function renderToHTMLOrFlightImpl(
     workStore,
     parsedRequestHeaders,
     getDynamicParamFromSegment,
+    interpolatedParams,
     query,
     isPrefetch: isPrefetchRequest,
     isPossibleServerAction: isPossibleActionRequest,