Skip to content

Commit d016157

Browse files
authored
Remove param values from static route tree (#82376)
Removes the param values from the router tree sent by the server during a client-side prefetch. This increases the cacheability of prefetch responses because if the response does not include a certain param in the component data, it can be reused for all possible values of that param. Previously this was not possible because even if the components did not reference a param, it was included in the router tree regardless. This does not affect the router tree sent during dynamic navigations, or during initial render. We should remove the param values from those responses, too, both for consistency and to increase the cacheability of HTML fallback responses. That work will land in future PRs.
1 parent 0dc264a commit d016157

File tree

5 files changed

+161
-76
lines changed

5 files changed

+161
-76
lines changed

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

Lines changed: 8 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,10 @@ 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'
34+
import {
35+
getRenderedPathname,
36+
urlToUrlWithoutFlightMarker,
37+
} from '../../route-params'
3538

3639
const createFromReadableStream =
3740
createFromReadableStreamBrowser as (typeof import('react-server-dom-webpack/client.browser'))['createFromReadableStream']
@@ -64,26 +67,11 @@ export type RequestHeaders = {
6467
'Next-Test-Fetch-Priority'?: RequestInit['priority']
6568
}
6669

67-
export function urlToUrlWithoutFlightMarker(url: string): URL {
68-
const urlWithoutFlightParameters = new URL(url, location.origin)
69-
urlWithoutFlightParameters.searchParams.delete(NEXT_RSC_UNION_QUERY)
70-
if (process.env.NODE_ENV === 'production') {
71-
if (
72-
process.env.__NEXT_CONFIG_OUTPUT === 'export' &&
73-
urlWithoutFlightParameters.pathname.endsWith('.txt')
74-
) {
75-
const { pathname } = urlWithoutFlightParameters
76-
const length = pathname.endsWith('/index.txt') ? 10 : 4
77-
// Slice off `/index.txt` or `.txt` from the end of the pathname
78-
urlWithoutFlightParameters.pathname = pathname.slice(0, -length)
79-
}
80-
}
81-
return urlWithoutFlightParameters
82-
}
83-
8470
function doMpaNavigation(url: string): FetchServerResponseResult {
8571
return {
86-
flightData: urlToUrlWithoutFlightMarker(url).toString(),
72+
flightData: urlToUrlWithoutFlightMarker(
73+
new URL(url, location.origin)
74+
).toString(),
8775
canonicalUrl: undefined,
8876
couldBeIntercepted: false,
8977
prerendered: false,
@@ -180,7 +168,7 @@ export async function fetchServerResponse(
180168
abortController.signal
181169
)
182170

183-
const responseUrl = urlToUrlWithoutFlightMarker(res.url)
171+
const responseUrl = urlToUrlWithoutFlightMarker(new URL(res.url))
184172
const canonicalUrl = res.redirected ? responseUrl : undefined
185173

186174
const contentType = res.headers.get('content-type') || ''

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

Lines changed: 67 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import type {
4545
} from './cache-key'
4646
import {
4747
doesStaticSegmentAppearInURL,
48+
getCacheKeyForDynamicParam,
4849
getRenderedPathname,
4950
getRenderedSearch,
5051
parseDynamicParamFromURLPart,
@@ -900,8 +901,11 @@ function convertRootTreePrefetchToRouteTree(
900901
// Remove trailing and leading slashes
901902
const pathnameParts = renderedPathname.split('/').filter((p) => p !== '')
902903
const index = 0
904+
const rootSegment = ROOT_SEGMENT_CACHE_KEY
903905
return convertTreePrefetchToRouteTree(
904906
rootTree.tree,
907+
rootSegment,
908+
null,
905909
ROOT_SEGMENT_REQUEST_KEY,
906910
ROOT_SEGMENT_CACHE_KEY,
907911
pathnameParts,
@@ -911,6 +915,8 @@ function convertRootTreePrefetchToRouteTree(
911915

912916
function convertTreePrefetchToRouteTree(
913917
prefetch: TreePrefetch,
918+
segment: FlightRouterStateSegment,
919+
param: RouteParam | null,
914920
requestKey: SegmentRequestKey,
915921
cacheKey: SegmentCacheKey,
916922
pathnameParts: Array<string>,
@@ -922,52 +928,71 @@ function convertTreePrefetchToRouteTree(
922928
// it once instead of on every access. This same cache key is also used to
923929
// request the segment from the server.
924930

925-
let segment = prefetch.segment
926-
927-
let doesAppearInURL: boolean
928-
let param: RouteParam | null = null
929-
if (Array.isArray(segment)) {
930-
// This segment is parameterized. Get the param from the pathname.
931-
const paramType = segment[2] as DynamicParamTypesShort
932-
const paramValue = parseDynamicParamFromURLPart(
933-
paramType,
934-
pathnameParts,
935-
pathnamePartsIndex
936-
)
937-
param = {
938-
name: segment[0],
939-
value: paramValue,
940-
type: paramType,
941-
}
942-
943-
// Assign a cache key to the segment, based on the param value. In the
944-
// pre-Segment Cache implementation, the server computes this and sends it
945-
// in the body of the response. In the Segment Cache implementation, the
946-
// server sends an empty string and we fill it in here.
947-
// TODO: This will land in a follow up PR.
948-
// segment[1] = getCacheKeyForDynamicParam(paramValue)
949-
950-
doesAppearInURL = true
951-
} else {
952-
doesAppearInURL = doesStaticSegmentAppearInURL(segment)
953-
}
954-
955-
// Only increment the index if the segment appears in the URL. If it's a
956-
// "virtual" segment, like a route group, it remains the same.
957-
const childPathnamePartsIndex = doesAppearInURL
958-
? pathnamePartsIndex + 1
959-
: pathnamePartsIndex
960-
961931
let slots: { [parallelRouteKey: string]: RouteTree } | null = null
962932
const prefetchSlots = prefetch.slots
963933
if (prefetchSlots !== null) {
964934
slots = {}
965935
for (let parallelRouteKey in prefetchSlots) {
966936
const childPrefetch = prefetchSlots[parallelRouteKey]
967-
const childSegment = childPrefetch.segment
968-
// TODO: Eventually, the param values will not be included in the response
969-
// from the server. We'll instead fill them in on the client by parsing
970-
// the URL. This is where we'll do that.
937+
const childParamName = childPrefetch.name
938+
const childParamType = childPrefetch.paramType
939+
const childServerSentParamKey = childPrefetch.paramKey
940+
941+
let childDoesAppearInURL: boolean
942+
let childParam: RouteParam | null = null
943+
let childSegment: FlightRouterStateSegment
944+
if (childParamType !== null) {
945+
// This segment is parameterized. Get the param from the pathname.
946+
const childParamValue = parseDynamicParamFromURLPart(
947+
childParamType,
948+
pathnameParts,
949+
pathnamePartsIndex
950+
)
951+
952+
// Assign a cache key to the segment, based on the param value. In the
953+
// pre-Segment Cache implementation, the server computes this and sends
954+
// it in the body of the response. In the Segment Cache implementation,
955+
// the server sends an empty string and we fill it in here.
956+
957+
// TODO: We're intentionally not adding the search param to page
958+
// segments here; it's tracked separately and added back during a read.
959+
// This would clearer if we waited to construct the segment until it's
960+
// read from the cache, since that's effectively what we're
961+
// doing anyway.
962+
const renderedSearch = '' as NormalizedSearch
963+
const childParamKey =
964+
// The server omits this field from the prefetch response when
965+
// clientParamParsing is enabled. The flag only exists while we're
966+
// testing the feature, in case there's a bug and we need to revert.
967+
// TODO: Remove once clientParamParsing is enabled everywhere.
968+
childServerSentParamKey !== null
969+
? childServerSentParamKey
970+
: // If no param key was sent, use the value parsed on the client.
971+
getCacheKeyForDynamicParam(childParamValue, renderedSearch)
972+
973+
childParam = {
974+
name: childParamName,
975+
value: childParamValue,
976+
type: childParamType,
977+
}
978+
childSegment = [
979+
childParamName,
980+
childParamKey,
981+
childParamType,
982+
childParamValue,
983+
]
984+
childDoesAppearInURL = true
985+
} else {
986+
childSegment = childParamName
987+
childDoesAppearInURL = doesStaticSegmentAppearInURL(childParamName)
988+
}
989+
990+
// Only increment the index if the segment appears in the URL. If it's a
991+
// "virtual" segment, like a route group, it remains the same.
992+
const childPathnamePartsIndex = childDoesAppearInURL
993+
? pathnamePartsIndex + 1
994+
: pathnamePartsIndex
995+
971996
const childRequestKeyPart = createSegmentRequestKeyPart(childSegment)
972997
const childRequestKey = appendSegmentRequestKeyPart(
973998
requestKey,
@@ -981,6 +1006,8 @@ function convertTreePrefetchToRouteTree(
9811006
)
9821007
slots[parallelRouteKey] = convertTreePrefetchToRouteTree(
9831008
childPrefetch,
1009+
childSegment,
1010+
childParam,
9841011
childRequestKey,
9851012
childCacheKey,
9861013
pathnameParts,

packages/next/src/client/route-params.ts

Lines changed: 49 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
import type { DynamicParamTypesShort } from '../server/app-render/types'
2-
import { PAGE_SEGMENT_KEY } from '../shared/lib/segment'
2+
import {
3+
addSearchParamsIfPageSegment,
4+
DEFAULT_SEGMENT_KEY,
5+
PAGE_SEGMENT_KEY,
6+
} from '../shared/lib/segment'
37
import { ROOT_SEGMENT_REQUEST_KEY } from '../shared/lib/segment-cache/segment-value-encoding'
48
import {
59
NEXT_REWRITTEN_PATH_HEADER,
610
NEXT_REWRITTEN_QUERY_HEADER,
11+
NEXT_RSC_UNION_QUERY,
712
} from './components/app-router-headers'
8-
import type { RSCResponse } from './components/router-reducer/fetch-server-response'
913
import type { NormalizedSearch } from './components/segment-cache'
14+
import type { RSCResponse } from './components/router-reducer/fetch-server-response'
1015

11-
type RouteParamValue = string | Array<string> | null
16+
export type RouteParamValue = string | Array<string> | null
1217

1318
export type RouteParam = {
1419
name: string
@@ -28,15 +33,18 @@ export function getRenderedSearch(response: RSCResponse): NormalizedSearch {
2833
}
2934
// If the header is not present, there was no rewrite, so we use the search
3035
// query of the response URL.
31-
return new URL(response.url).search as NormalizedSearch
36+
return urlToUrlWithoutFlightMarker(new URL(response.url))
37+
.search as NormalizedSearch
3238
}
3339

3440
export function getRenderedPathname(response: RSCResponse): string {
3541
// If the server performed a rewrite, the pathname used to render the
3642
// page will be different from the pathname in the request URL. In this case,
3743
// the response will include a header that gives the rewritten pathname.
3844
const rewrittenPath = response.headers.get(NEXT_REWRITTEN_PATH_HEADER)
39-
return rewrittenPath ?? new URL(response.url).pathname
45+
return (
46+
rewrittenPath ?? urlToUrlWithoutFlightMarker(new URL(response.url)).pathname
47+
)
4048
}
4149

4250
export function parseDynamicParamFromURLPart(
@@ -101,7 +109,9 @@ export function doesStaticSegmentAppearInURL(segment: string): boolean {
101109
// TODO: Investigate why the loader produces these fake page segments.
102110
segment.startsWith(PAGE_SEGMENT_KEY) ||
103111
// Route groups.
104-
(segment[0] === '(' && segment.endsWith(')'))
112+
(segment[0] === '(' && segment.endsWith(')')) ||
113+
segment === DEFAULT_SEGMENT_KEY ||
114+
segment === '/_not-found'
105115
) {
106116
return false
107117
} else {
@@ -111,14 +121,41 @@ export function doesStaticSegmentAppearInURL(segment: string): boolean {
111121
}
112122

113123
export function getCacheKeyForDynamicParam(
114-
paramValue: RouteParamValue
124+
paramValue: RouteParamValue,
125+
renderedSearch: NormalizedSearch
115126
): string {
116127
// This needs to match the logic in get-dynamic-param.ts, until we're able to
117128
// unify the various implementations so that these are always computed on
118129
// the client.
119-
return typeof paramValue === 'string'
120-
? paramValue
121-
: paramValue === null
122-
? ''
123-
: paramValue.join('/')
130+
if (typeof paramValue === 'string') {
131+
// TODO: Refactor or remove this helper function to accept a string rather
132+
// than the whole segment type. Also we can probably just append the
133+
// search string instead of turning it into JSON.
134+
const pageSegmentWithSearchParams = addSearchParamsIfPageSegment(
135+
paramValue,
136+
Object.fromEntries(new URLSearchParams(renderedSearch))
137+
) as string
138+
return pageSegmentWithSearchParams
139+
} else if (paramValue === null) {
140+
return ''
141+
} else {
142+
return paramValue.join('/')
143+
}
144+
}
145+
146+
export function urlToUrlWithoutFlightMarker(url: URL): URL {
147+
const urlWithoutFlightParameters = new URL(url)
148+
urlWithoutFlightParameters.searchParams.delete(NEXT_RSC_UNION_QUERY)
149+
if (process.env.NODE_ENV === 'production') {
150+
if (
151+
process.env.__NEXT_CONFIG_OUTPUT === 'export' &&
152+
urlWithoutFlightParameters.pathname.endsWith('.txt')
153+
) {
154+
const { pathname } = urlWithoutFlightParameters
155+
const length = pathname.endsWith('/index.txt') ? 10 : 4
156+
// Slice off `/index.txt` or `.txt` from the end of the pathname
157+
urlWithoutFlightParameters.pathname = pathname.slice(0, -length)
158+
}
159+
}
160+
return urlWithoutFlightParameters
124161
}

packages/next/src/server/app-render/app-render.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4679,6 +4679,7 @@ async function collectSegmentData(
46794679

46804680
const staleTime = prerenderStore.stale
46814681
return await ComponentMod.collectSegmentData(
4682+
renderOpts.experimental.clientParamParsing,
46824683
fullPageDataBuffer,
46834684
staleTime,
46844685
clientReferenceManifest.clientModules as ManifestNode,

0 commit comments

Comments
 (0)