Skip to content

Parse dynamic params on the client #82185

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 31, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,26 @@ export function createInitialRouterState({
// This is to ensure that when the RSC payload streamed to the client, crawlers don't interpret it
// as a URL that should be crawled.
const initialCanonicalUrl = initialCanonicalUrlParts.join('/')
const normalizedFlightData = getFlightDataPartsFromPath(initialFlightData[0])

// TODO: Eventually we want to always read the rendered params from the URL
// and/or the x-rewritten-path header, so that we can omit them from the
// response body. This lets reuse cached responses if params aren't referenced
// anywhere in the actual page data, e.g. if they're only accessed by client
// components. However, during the initial render, there's no way to access
// the headers. For a partially dynamic page, this is OK, because there's
// going to be a dynamic server render regardless, so we can send the URL
// in the resume body. For a completely static page, though, there's no
// dynamic server render, so that won't work.
//
// Instead, we'll perform a HEAD request and read the rewritten URL from
// that response.
const renderedPathname = new URL(initialCanonicalUrl, 'http://localhost')
.pathname

const normalizedFlightData = getFlightDataPartsFromPath(
initialFlightData[0],
renderedPathname
)
const {
tree: initialTree,
seedData: initialSeedData,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
} from '../../flight-data-helpers'
import { getAppBuildId } from '../../app-build-id'
import { setCacheBustingSearchParam } from './set-cache-busting-search-param'
import { getRenderedPathname } from '../../route-params'

const createFromReadableStream =
createFromReadableStreamBrowser as (typeof import('react-server-dom-webpack/client.browser'))['createFromReadableStream']
Expand Down Expand Up @@ -235,8 +236,21 @@ export async function fetchServerResponse(
return doMpaNavigation(res.url)
}

let renderedPathname
if (process.env.__NEXT_CLIENT_SEGMENT_CACHE) {
// Read the URL from the response object.
renderedPathname = getRenderedPathname(res)
} else {
// Before Segment Cache is enabled, we should not rely on the new
// rewrite headers (x-rewritten-path, x-rewritten-query) because that
// is a breaking change. Read the URL from the response body.
const renderedUrlParts = response.c
renderedPathname = new URL(renderedUrlParts.join('/'), 'http://localhost')
.pathname
}

return {
flightData: normalizeFlightData(response.f),
flightData: normalizeFlightData(response.f, renderedPathname),
canonicalUrl: canonicalUrl,
couldBeIntercepted: interception,
prerendered: response.S,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
omitUnusedArgs,
} from '../../../../shared/lib/server-reference-info'
import { revalidateEntireCache } from '../../segment-cache'
import { getRenderedPathname } from '../../../route-params'

const createFromFetch =
createFromFetchBrowser as (typeof import('react-server-dom-webpack/client.browser'))['createFromFetch']
Expand Down Expand Up @@ -180,9 +181,25 @@ async function fetchServerAction(
Promise.resolve(res),
{ callServer, findSourceMapURL, temporaryReferences }
)

let renderedPathname
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let renderedPathname
let renderedPathname: string

if (process.env.__NEXT_CLIENT_SEGMENT_CACHE) {
// Read the URL from the response object.
renderedPathname = getRenderedPathname(res)
} else {
// Before Segment Cache is enabled, we should not rely on the new
// rewrite headers (x-rewritten-path, x-rewritten-query) because that
// is a breaking change. Read the URL from the response body.
const canonicalUrlParts = response.c
renderedPathname = new URL(
canonicalUrlParts.join('/'),
'http://localhost'
).pathname
}

// An internal redirect can send an RSC response, but does not have a useful `actionResult`.
actionResult = redirectLocation ? undefined : response.a
actionFlightData = normalizeFlightData(response.f)
actionFlightData = normalizeFlightData(response.f, renderedPathname)
} else {
// An external redirect doesn't contain RSC data.
actionResult = undefined
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
import { NEXT_REWRITTEN_QUERY_HEADER } from '../app-router-headers'
import type { RSCResponse } from '../router-reducer/fetch-server-response'

// TypeScript trick to simulate opaque types, like in Flow.
type Opaque<K, T> = T & { __brand: K }

Expand Down Expand Up @@ -32,18 +29,3 @@ export function createCacheKey(
} as RouteCacheKey
return cacheKey
}

export function getRenderedSearch(response: RSCResponse): NormalizedSearch {
// If the server performed a rewrite, the search params used to render the
// page will be different from the params in the request URL. In this case,
// the response will include a header that gives the rewritten search query.
const rewrittenQuery = response.headers.get(NEXT_REWRITTEN_QUERY_HEADER)
if (rewrittenQuery !== null) {
return (
rewrittenQuery === '' ? '' : '?' + rewrittenQuery
) as NormalizedSearch
}
// If the header is not present, there was no rewrite, so we use the search
// query of the response URL.
return new URL(response.url).search as NormalizedSearch
}
155 changes: 123 additions & 32 deletions packages/next/src/client/components/segment-cache-impl/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
} from '../../../shared/lib/app-router-context.shared-runtime'
import type {
CacheNodeSeedData,
DynamicParamTypesShort,
Segment as FlightRouterStateSegment,
} from '../../../server/app-render/types'
import { HasLoadingBoundary } from '../../../server/app-render/types'
Expand Down Expand Up @@ -42,7 +43,13 @@ import type {
NormalizedSearch,
RouteCacheKey,
} from './cache-key'
import { getRenderedSearch } from './cache-key'
import {
doesStaticSegmentAppearInURL,
getRenderedPathname,
getRenderedSearch,
parseDynamicParamFromURLPart,
type RouteParam,
} from '../../route-params'
import { createTupleMap, type TupleMap, type Prefix } from './tuple-map'
import { createLRU } from './lru'
import {
Expand Down Expand Up @@ -88,7 +95,10 @@ import { FetchStrategy } from '../segment-cache'

export type RouteTree = {
key: string
// TODO: Remove the `segment` field, now that it can be reconstructed
// from `param`.
segment: FlightRouterStateSegment
param: RouteParam | null
slots: null | {
[parallelRouteKey: string]: RouteTree
}
Expand Down Expand Up @@ -869,19 +879,69 @@ function rejectSegmentCacheEntry(
}
}

function convertRootTreePrefetchToRouteTree(rootTree: RootTreePrefetch) {
return convertTreePrefetchToRouteTree(rootTree.tree, ROOT_SEGMENT_KEY)
function convertRootTreePrefetchToRouteTree(
rootTree: RootTreePrefetch,
renderedPathname: string
) {
// Remove trailing and leading slashes
const pathnameParts = renderedPathname.split('/').filter((p) => p !== '')
const index = 0
return convertTreePrefetchToRouteTree(
rootTree.tree,
ROOT_SEGMENT_KEY,
pathnameParts,
index
)
}

function convertTreePrefetchToRouteTree(
prefetch: TreePrefetch,
key: string
key: string,
pathnameParts: Array<string>,
pathnamePartsIndex: number
): RouteTree {
// Converts the route tree sent by the server into the format used by the
// cache. The cached version of the tree includes additional fields, such as a
// cache key for each segment. Since this is frequently accessed, we compute
// it once instead of on every access. This same cache key is also used to
// request the segment from the server.

let segment = prefetch.segment

let doesAppearInURL: boolean
let param: RouteParam | null = null
if (Array.isArray(segment)) {
// This segment is parameterized. Get the param from the pathname.
const paramType = segment[2] as DynamicParamTypesShort
const paramValue = parseDynamicParamFromURLPart(
paramType,
pathnameParts,
pathnamePartsIndex
)
param = {
name: segment[0],
value: paramValue,
type: paramType,
}

// Assign a cache key to the segment, based on the param value. In the
// pre-Segment Cache implementation, the server computes this and sends it
// in the body of the response. In the Segment Cache implementation, the
// server sends an empty string and we fill it in here.
// TODO: This will land in a follow up PR.
// segment[1] = getCacheKeyForDynamicParam(paramValue)

doesAppearInURL = true
} else {
doesAppearInURL = doesStaticSegmentAppearInURL(segment)
}

// Only increment the index if the segment appears in the URL. If it's a
// "virtual" segment, like a route group, it remains the same.
const childPathnamePartsIndex = doesAppearInURL
? pathnamePartsIndex + 1
: pathnamePartsIndex

let slots: { [parallelRouteKey: string]: RouteTree } | null = null
const prefetchSlots = prefetch.slots
if (prefetchSlots !== null) {
Expand All @@ -899,13 +959,17 @@ function convertTreePrefetchToRouteTree(
)
slots[parallelRouteKey] = convertTreePrefetchToRouteTree(
childPrefetch,
childKey
childKey,
pathnameParts,
childPathnamePartsIndex
)
}
}

return {
key,
segment: prefetch.segment,
segment,
param,
slots,
isRootLayout: prefetch.isRootLayout,
// This field is only relevant to dynamic routes. For a PPR/static route,
Expand Down Expand Up @@ -953,26 +1017,39 @@ function convertFlightRouterStateToRouteTree(
slots[parallelRouteKey] = childTree
}
}

// The navigation implementation expects the search params to be included
// in the segment. However, in the case of a static response, the search
// params are omitted. So the client needs to add them back in when reading
// from the Segment Cache.
//
// For consistency, we'll do this for dynamic responses, too.
//
// TODO: We should move search params out of FlightRouterState and handle them
// entirely on the client, similar to our plan for dynamic params.
const originalSegment = flightRouterState[0]
const segmentWithoutSearchParams =
typeof originalSegment === 'string' &&
originalSegment.startsWith(PAGE_SEGMENT_KEY)
? PAGE_SEGMENT_KEY
: originalSegment

let segment: FlightRouterStateSegment
let param: RouteParam | null = null
if (Array.isArray(originalSegment)) {
const paramValue = originalSegment[3]
param = {
name: originalSegment[0],
value: paramValue === undefined ? null : paramValue,
type: originalSegment[2] as DynamicParamTypesShort,
}
segment = originalSegment
} else {
// The navigation implementation expects the search params to be included
// in the segment. However, in the case of a static response, the search
// params are omitted. So the client needs to add them back in when reading
// from the Segment Cache.
//
// For consistency, we'll do this for dynamic responses, too.
//
// TODO: We should move search params out of FlightRouterState and handle
// them entirely on the client, similar to our plan for dynamic params.
segment =
typeof originalSegment === 'string' &&
originalSegment.startsWith(PAGE_SEGMENT_KEY)
? PAGE_SEGMENT_KEY
: originalSegment
}

return {
key,
segment: segmentWithoutSearchParams,
segment,
param,
slots,
isRootLayout: flightRouterState[4] === true,
hasLoadingBoundary:
Expand Down Expand Up @@ -1157,15 +1234,21 @@ export async function fetchRouteOnCacheMiss(
return null
}

// Get the search params that were used to render the target page. This may
// be different from the search params in the request URL, if the page
// Get the params that were used to render the target page. These may
// be different from the params in the request URL, if the page
// was rewritten.
const renderedPathname = getRenderedPathname(response)
const renderedSearch = getRenderedSearch(response)

const routeTree = convertRootTreePrefetchToRouteTree(
serverData,
renderedPathname
)

const staleTimeMs = serverData.staleTime * 1000
fulfillRouteCacheEntry(
entry,
convertRootTreePrefetchToRouteTree(serverData),
routeTree,
serverData.head,
serverData.isHeadPartial,
Date.now() + staleTimeMs,
Expand Down Expand Up @@ -1499,7 +1582,15 @@ function writeDynamicTreeResponseIntoCache(
canonicalUrl: string,
routeIsPPREnabled: boolean
) {
const normalizedFlightDataResult = normalizeFlightData(serverData.f)
// Get the URL that was used to render the target page. This may be different
// from the URL in the request URL, if the page was rewritten.
const renderedSearch = getRenderedSearch(response)
const renderedPathname = getRenderedPathname(response)

const normalizedFlightDataResult = normalizeFlightData(
serverData.f,
renderedPathname
)
if (
// A string result means navigating to this route will result in an
// MPA navigation.
Expand Down Expand Up @@ -1533,11 +1624,6 @@ function writeDynamicTreeResponseIntoCache(
const isResponsePartial =
response.headers.get(NEXT_DID_POSTPONE_HEADER) === '1'

// Get the search params that were used to render the target page. This may
// be different from the search params in the request URL, if the page
// was rewritten.
const renderedSearch = getRenderedSearch(response)

const fulfilledEntry = fulfillRouteCacheEntry(
entry,
convertRootFlightRouterStateToRouteTree(flightRouterState),
Expand Down Expand Up @@ -1610,7 +1696,12 @@ function writeDynamicRenderResponseIntoCache(
}
return null
}
const flightDatas = normalizeFlightData(serverData.f)

// Get the URL that was used to render the target page. This may be different
// from the URL in the request URL, if the page was rewritten.
const renderedPathname = getRenderedPathname(response)

const flightDatas = normalizeFlightData(serverData.f, renderedPathname)
if (typeof flightDatas === 'string') {
// This means navigating to this route will result in an MPA navigation.
// TODO: We should cache this, too, so that the MPA navigation is immediate.
Expand Down
1 change: 1 addition & 0 deletions packages/next/src/client/components/segment-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

export type { NavigationResult } from './segment-cache-impl/navigation'
export type { PrefetchTask } from './segment-cache-impl/scheduler'
export type { NormalizedSearch } from './segment-cache-impl/cache-key'

const notEnabled: any = () => {
throw new Error(
Expand Down
Loading
Loading