Skip to content

Commit 43445f6

Browse files
committed
Parse dynamic params on the client
This is part of a larger effort to remove dynamic params from server responses except in cases where they are needed to render a Server Component. If a param is not rendered by a Server Component, then it can omitted from the cache key, and cached responses can be reused across pages with different param values. In this step, I've implemented client parsing of the params from the response headers. The basic approach is to split the URL into parts, then traverse the route tree to pick off the param values, taking care to skip over things like interception routes. Notably, this is not how the server parses param values. The server gets the params from the regex that's also used for routing. Originally, I thought I'd send the regex to the client and use it there, too. However, this ended up being needlessly complicated, because the server regexes are also used for things like interception route matching. But this is already encapsulated by the structure of the route tree. So it's easier to just walk the tree and pick off the params. My main hesitation is that there's drift between the server and client params parsing that we'll need to keep in sync. However, I think the solution is actually to update the server to also pick the params off the URL, rather than use the ones passed from the base router. It's conceptually cleaner, since it's less likely that extra concerns from the base server leaking into the application layer. As an example, compare the code needed to get a catchall param value in the client versus the server implementation. Note: Although the ultimate goal is to remove the dynamic params from the response body (e.g. FlightRouterState), I have not done so yet this PR. The rest of the work will be split up into multiple subsequent PRs.
1 parent 7351d92 commit 43445f6

File tree

10 files changed

+301
-58
lines changed

10 files changed

+301
-58
lines changed

packages/next/errors.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -764,5 +764,6 @@
764764
"763": "\\`unstable_rootParams\\` must not be used within a client component. Next.js should be preventing it from being included in client components statically, but did not in this case.",
765765
"764": "Missing workStore in %s",
766766
"765": "Route %s used %s inside a Route Handler. Support for this API in Route Handlers is planned for a future version of Next.js.",
767-
"766": "%s was used inside a Server Action. This is not supported. Functions from 'next/root-params' can only be called in the context of a route."
767+
"766": "%s was used inside a Server Action. This is not supported. Functions from 'next/root-params' can only be called in the context of a route.",
768+
"767": "An unexpected response was received from the server."
768769
}

packages/next/src/client/components/router-reducer/create-initial-router-state.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,26 @@ export function createInitialRouterState({
3737
// This is to ensure that when the RSC payload streamed to the client, crawlers don't interpret it
3838
// as a URL that should be crawled.
3939
const initialCanonicalUrl = initialCanonicalUrlParts.join('/')
40-
const normalizedFlightData = getFlightDataPartsFromPath(initialFlightData[0])
40+
41+
// TODO: Eventually we want to always read the rendered params from the URL
42+
// and/or the x-rewritten-path header, so that we can omit them from the
43+
// response body. This lets reuse cached responses if params aren't referenced
44+
// anywhere in the actual page data, e.g. if they're only accessed by client
45+
// components. However, during the initial render, there's no way to access
46+
// the headers. For a partially dynamic page, this is OK, because there's
47+
// going to be a dynamic server render regardless, so we can send the URL
48+
// in the resume body. For a completely static page, though, there's no
49+
// dynamic server render, so that won't work.
50+
//
51+
// Instead, we'll perform a HEAD request and read the rewritten URL from
52+
// that response.
53+
const renderedPathname = new URL(initialCanonicalUrl, 'http://localhost')
54+
.pathname
55+
56+
const normalizedFlightData = getFlightDataPartsFromPath(
57+
initialFlightData[0],
58+
renderedPathname
59+
)
4160
const {
4261
tree: initialTree,
4362
seedData: initialSeedData,

packages/next/src/client/components/router-reducer/fetch-server-response.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
} from '../../flight-data-helpers'
3232
import { getAppBuildId } from '../../app-build-id'
3333
import { setCacheBustingSearchParam } from './set-cache-busting-search-param'
34+
import { getRenderedPathname } from '../../route-params'
3435

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

239+
let renderedPathname
240+
if (process.env.__NEXT_CLIENT_SEGMENT_CACHE) {
241+
// Read the URL from the response object.
242+
renderedPathname = getRenderedPathname(res)
243+
} else {
244+
// Before Segment Cache is enabled, we should not rely on the new
245+
// rewrite headers (x-rewritten-path, x-rewritten-query) because that
246+
// is a breaking change. Read the URL from the response body.
247+
const renderedUrlParts = response.c
248+
renderedPathname = new URL(renderedUrlParts.join('/'), 'http://localhost')
249+
.pathname
250+
}
251+
238252
return {
239-
flightData: normalizeFlightData(response.f),
253+
flightData: normalizeFlightData(response.f, renderedPathname),
240254
canonicalUrl: canonicalUrl,
241255
couldBeIntercepted: interception,
242256
prerendered: response.S,

packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import {
5555
omitUnusedArgs,
5656
} from '../../../../shared/lib/server-reference-info'
5757
import { revalidateEntireCache } from '../../segment-cache'
58+
import { getRenderedPathname } from '../../../route-params'
5859

5960
const createFromFetch =
6061
createFromFetchBrowser as (typeof import('react-server-dom-webpack/client.browser'))['createFromFetch']
@@ -180,9 +181,25 @@ async function fetchServerAction(
180181
Promise.resolve(res),
181182
{ callServer, findSourceMapURL, temporaryReferences }
182183
)
184+
185+
let renderedPathname
186+
if (process.env.__NEXT_CLIENT_SEGMENT_CACHE) {
187+
// Read the URL from the response object.
188+
renderedPathname = getRenderedPathname(res)
189+
} else {
190+
// Before Segment Cache is enabled, we should not rely on the new
191+
// rewrite headers (x-rewritten-path, x-rewritten-query) because that
192+
// is a breaking change. Read the URL from the response body.
193+
const canonicalUrlParts = response.c
194+
renderedPathname = new URL(
195+
canonicalUrlParts.join('/'),
196+
'http://localhost'
197+
).pathname
198+
}
199+
183200
// An internal redirect can send an RSC response, but does not have a useful `actionResult`.
184201
actionResult = redirectLocation ? undefined : response.a
185-
actionFlightData = normalizeFlightData(response.f)
202+
actionFlightData = normalizeFlightData(response.f, renderedPathname)
186203
} else {
187204
// An external redirect doesn't contain RSC data.
188205
actionResult = undefined

packages/next/src/client/components/segment-cache-impl/cache-key.ts

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
import { NEXT_REWRITTEN_QUERY_HEADER } from '../app-router-headers'
2-
import type { RSCResponse } from '../router-reducer/fetch-server-response'
3-
41
// TypeScript trick to simulate opaque types, like in Flow.
52
type Opaque<K, T> = T & { __brand: K }
63

@@ -32,18 +29,3 @@ export function createCacheKey(
3229
} as RouteCacheKey
3330
return cacheKey
3431
}
35-
36-
export function getRenderedSearch(response: RSCResponse): NormalizedSearch {
37-
// If the server performed a rewrite, the search params used to render the
38-
// page will be different from the params in the request URL. In this case,
39-
// the response will include a header that gives the rewritten search query.
40-
const rewrittenQuery = response.headers.get(NEXT_REWRITTEN_QUERY_HEADER)
41-
if (rewrittenQuery !== null) {
42-
return (
43-
rewrittenQuery === '' ? '' : '?' + rewrittenQuery
44-
) as NormalizedSearch
45-
}
46-
// If the header is not present, there was no rewrite, so we use the search
47-
// query of the response URL.
48-
return new URL(response.url).search as NormalizedSearch
49-
}

packages/next/src/client/components/segment-cache-impl/cache.ts

Lines changed: 114 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99
} from '../../../shared/lib/app-router-context.shared-runtime'
1010
import type {
1111
CacheNodeSeedData,
12+
DynamicParamTypesShort,
1213
Segment as FlightRouterStateSegment,
1314
} from '../../../server/app-render/types'
1415
import { HasLoadingBoundary } from '../../../server/app-render/types'
@@ -42,7 +43,13 @@ import type {
4243
NormalizedSearch,
4344
RouteCacheKey,
4445
} from './cache-key'
45-
import { getRenderedSearch } from './cache-key'
46+
import {
47+
doesStaticSegmentAppearInURL,
48+
getRenderedPathname,
49+
getRenderedSearch,
50+
parseDynamicParamFromURLPart,
51+
type RouteParam,
52+
} from '../../route-params'
4653
import { createTupleMap, type TupleMap, type Prefix } from './tuple-map'
4754
import { createLRU } from './lru'
4855
import {
@@ -88,7 +95,10 @@ import { FetchStrategy } from '../segment-cache'
8895

8996
export type RouteTree = {
9097
key: string
98+
// TODO: Remove the `segment` field, now that it can be reconstructed
99+
// from `param`.
91100
segment: FlightRouterStateSegment
101+
param: RouteParam | null
92102
slots: null | {
93103
[parallelRouteKey: string]: RouteTree
94104
}
@@ -851,19 +861,60 @@ function rejectSegmentCacheEntry(
851861
}
852862
}
853863

854-
function convertRootTreePrefetchToRouteTree(rootTree: RootTreePrefetch) {
855-
return convertTreePrefetchToRouteTree(rootTree.tree, ROOT_SEGMENT_KEY)
864+
function convertRootTreePrefetchToRouteTree(
865+
rootTree: RootTreePrefetch,
866+
renderedPathname: string
867+
) {
868+
// Remove trailing and leading slashes
869+
const pathnameParts = renderedPathname.replace(/^\/|\/$/g, '').split('/')
870+
const index = 0
871+
return convertTreePrefetchToRouteTree(
872+
rootTree.tree,
873+
ROOT_SEGMENT_KEY,
874+
pathnameParts,
875+
index
876+
)
856877
}
857878

858879
function convertTreePrefetchToRouteTree(
859880
prefetch: TreePrefetch,
860-
key: string
881+
key: string,
882+
pathnameParts: Array<string>,
883+
pathnamePartsIndex: number
861884
): RouteTree {
862885
// Converts the route tree sent by the server into the format used by the
863886
// cache. The cached version of the tree includes additional fields, such as a
864887
// cache key for each segment. Since this is frequently accessed, we compute
865888
// it once instead of on every access. This same cache key is also used to
866889
// request the segment from the server.
890+
891+
const segment = prefetch.segment
892+
893+
let doesAppearInURL: boolean
894+
let param: RouteParam | null = null
895+
if (Array.isArray(segment)) {
896+
// This segment is parameterized. Get the param from the pathname.
897+
const paramType = segment[2] as DynamicParamTypesShort
898+
param = {
899+
name: segment[0],
900+
value: parseDynamicParamFromURLPart(
901+
paramType,
902+
pathnameParts,
903+
pathnamePartsIndex
904+
),
905+
type: paramType,
906+
}
907+
doesAppearInURL = true
908+
} else {
909+
doesAppearInURL = doesStaticSegmentAppearInURL(segment)
910+
}
911+
912+
// Only increment the index if the segment appears in the URL. If it's a
913+
// "virtual" segment, like a route group, it remains the same.
914+
const childPathnamePartsIndex = doesAppearInURL
915+
? pathnamePartsIndex + 1
916+
: pathnamePartsIndex
917+
867918
let slots: { [parallelRouteKey: string]: RouteTree } | null = null
868919
const prefetchSlots = prefetch.slots
869920
if (prefetchSlots !== null) {
@@ -881,13 +932,17 @@ function convertTreePrefetchToRouteTree(
881932
)
882933
slots[parallelRouteKey] = convertTreePrefetchToRouteTree(
883934
childPrefetch,
884-
childKey
935+
childKey,
936+
pathnameParts,
937+
childPathnamePartsIndex
885938
)
886939
}
887940
}
941+
888942
return {
889943
key,
890-
segment: prefetch.segment,
944+
segment,
945+
param,
891946
slots,
892947
isRootLayout: prefetch.isRootLayout,
893948
// This field is only relevant to dynamic routes. For a PPR/static route,
@@ -935,26 +990,39 @@ function convertFlightRouterStateToRouteTree(
935990
slots[parallelRouteKey] = childTree
936991
}
937992
}
938-
939-
// The navigation implementation expects the search params to be included
940-
// in the segment. However, in the case of a static response, the search
941-
// params are omitted. So the client needs to add them back in when reading
942-
// from the Segment Cache.
943-
//
944-
// For consistency, we'll do this for dynamic responses, too.
945-
//
946-
// TODO: We should move search params out of FlightRouterState and handle them
947-
// entirely on the client, similar to our plan for dynamic params.
948993
const originalSegment = flightRouterState[0]
949-
const segmentWithoutSearchParams =
950-
typeof originalSegment === 'string' &&
951-
originalSegment.startsWith(PAGE_SEGMENT_KEY)
952-
? PAGE_SEGMENT_KEY
953-
: originalSegment
994+
995+
let segment: FlightRouterStateSegment
996+
let param: RouteParam | null = null
997+
if (Array.isArray(originalSegment)) {
998+
const paramValue = originalSegment[3]
999+
param = {
1000+
name: originalSegment[0],
1001+
value: paramValue === undefined ? null : paramValue,
1002+
type: originalSegment[2] as DynamicParamTypesShort,
1003+
}
1004+
segment = originalSegment
1005+
} else {
1006+
// The navigation implementation expects the search params to be included
1007+
// in the segment. However, in the case of a static response, the search
1008+
// params are omitted. So the client needs to add them back in when reading
1009+
// from the Segment Cache.
1010+
//
1011+
// For consistency, we'll do this for dynamic responses, too.
1012+
//
1013+
// TODO: We should move search params out of FlightRouterState and handle
1014+
// them entirely on the client, similar to our plan for dynamic params.
1015+
segment =
1016+
typeof originalSegment === 'string' &&
1017+
originalSegment.startsWith(PAGE_SEGMENT_KEY)
1018+
? PAGE_SEGMENT_KEY
1019+
: originalSegment
1020+
}
9541021

9551022
return {
9561023
key,
957-
segment: segmentWithoutSearchParams,
1024+
segment,
1025+
param,
9581026
slots,
9591027
isRootLayout: flightRouterState[4] === true,
9601028
hasLoadingBoundary:
@@ -1139,15 +1207,21 @@ export async function fetchRouteOnCacheMiss(
11391207
return null
11401208
}
11411209

1142-
// Get the search params that were used to render the target page. This may
1143-
// be different from the search params in the request URL, if the page
1210+
// Get the params that were used to render the target page. These may
1211+
// be different from the params in the request URL, if the page
11441212
// was rewritten.
1213+
const renderedPathname = getRenderedPathname(response)
11451214
const renderedSearch = getRenderedSearch(response)
11461215

1216+
const routeTree = convertRootTreePrefetchToRouteTree(
1217+
serverData,
1218+
renderedPathname
1219+
)
1220+
11471221
const staleTimeMs = serverData.staleTime * 1000
11481222
fulfillRouteCacheEntry(
11491223
entry,
1150-
convertRootTreePrefetchToRouteTree(serverData),
1224+
routeTree,
11511225
serverData.head,
11521226
serverData.isHeadPartial,
11531227
Date.now() + staleTimeMs,
@@ -1458,7 +1532,15 @@ function writeDynamicTreeResponseIntoCache(
14581532
canonicalUrl: string,
14591533
routeIsPPREnabled: boolean
14601534
) {
1461-
const normalizedFlightDataResult = normalizeFlightData(serverData.f)
1535+
// Get the URL that was used to render the target page. This may be different
1536+
// from the URL in the request URL, if the page was rewritten.
1537+
const renderedSearch = getRenderedSearch(response)
1538+
const renderedPathname = getRenderedPathname(response)
1539+
1540+
const normalizedFlightDataResult = normalizeFlightData(
1541+
serverData.f,
1542+
renderedPathname
1543+
)
14621544
if (
14631545
// A string result means navigating to this route will result in an
14641546
// MPA navigation.
@@ -1492,11 +1574,6 @@ function writeDynamicTreeResponseIntoCache(
14921574
const isResponsePartial =
14931575
response.headers.get(NEXT_DID_POSTPONE_HEADER) === '1'
14941576

1495-
// Get the search params that were used to render the target page. This may
1496-
// be different from the search params in the request URL, if the page
1497-
// was rewritten.
1498-
const renderedSearch = getRenderedSearch(response)
1499-
15001577
const fulfilledEntry = fulfillRouteCacheEntry(
15011578
entry,
15021579
convertRootFlightRouterStateToRouteTree(flightRouterState),
@@ -1566,7 +1643,12 @@ function writeDynamicRenderResponseIntoCache(
15661643
}
15671644
return null
15681645
}
1569-
const flightDatas = normalizeFlightData(serverData.f)
1646+
1647+
// Get the URL that was used to render the target page. This may be different
1648+
// from the URL in the request URL, if the page was rewritten.
1649+
const renderedPathname = getRenderedPathname(response)
1650+
1651+
const flightDatas = normalizeFlightData(serverData.f, renderedPathname)
15701652
if (typeof flightDatas === 'string') {
15711653
// This means navigating to this route will result in an MPA navigation.
15721654
// TODO: We should cache this, too, so that the MPA navigation is immediate.

packages/next/src/client/components/segment-cache.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
export type { NavigationResult } from './segment-cache-impl/navigation'
2121
export type { PrefetchTask } from './segment-cache-impl/scheduler'
22+
export type { NormalizedSearch } from './segment-cache-impl/cache-key'
2223

2324
const notEnabled: any = () => {
2425
throw new Error(

0 commit comments

Comments
 (0)