Skip to content

Commit bd96b91

Browse files
authored
Fix: Preserve intentional percent encoding in search param for client nav (vercel#74473)
1 parent bca0bc9 commit bd96b91

File tree

5 files changed

+201
-17
lines changed

5 files changed

+201
-17
lines changed

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

Lines changed: 4 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,10 @@ import type {
1515
FlightRouterState,
1616
NavigationFlightResponse,
1717
} from '../../../server/app-render/types'
18+
19+
import type { NEXT_ROUTER_SEGMENT_PREFETCH_HEADER } from '../app-router-headers'
1820
import {
1921
NEXT_ROUTER_PREFETCH_HEADER,
20-
NEXT_ROUTER_SEGMENT_PREFETCH_HEADER,
2122
NEXT_ROUTER_STATE_TREE_HEADER,
2223
NEXT_RSC_UNION_QUERY,
2324
NEXT_URL,
@@ -30,12 +31,12 @@ import {
3031
import { callServer } from '../../app-call-server'
3132
import { findSourceMapURL } from '../../app-find-source-map-url'
3233
import { PrefetchKind } from './router-reducer-types'
33-
import { hexHash } from '../../../shared/lib/hash'
3434
import {
3535
normalizeFlightData,
3636
type NormalizedFlightData,
3737
} from '../../flight-data-helpers'
3838
import { getAppBuildId } from '../../app-build-id'
39+
import { setCacheBustingSearchParam } from './set-cache-busting-search-param'
3940

4041
export interface FetchServerResponseOptions {
4142
readonly flightRouterState: FlightRouterState
@@ -264,21 +265,7 @@ export function createFetch(
264265
}
265266
}
266267

267-
// This is used to cache bust CDNs that don't support custom headers. The
268-
// result is stored in a search param.
269-
// TODO: Given that we have to use a search param anyway, we might as well
270-
// _only_ use a search param and not bother with the custom headers.
271-
// Add unique cache query to avoid caching conflicts on CDN which don't respect the Vary header
272-
const uniqueCacheQuery = hexHash(
273-
[
274-
headers[NEXT_ROUTER_PREFETCH_HEADER] || '0',
275-
headers[NEXT_ROUTER_SEGMENT_PREFETCH_HEADER] || '0',
276-
headers[NEXT_ROUTER_STATE_TREE_HEADER],
277-
headers[NEXT_URL],
278-
].join(',')
279-
)
280-
281-
fetchUrl.searchParams.set(NEXT_RSC_UNION_QUERY, uniqueCacheQuery)
268+
setCacheBustingSearchParam(fetchUrl, headers)
282269

283270
if (process.env.__NEXT_TEST_MODE && fetchPriority !== null) {
284271
headers['Next-Test-Fetch-Priority'] = fetchPriority
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { setCacheBustingSearchParam } from './set-cache-busting-search-param'
2+
import {
3+
NEXT_ROUTER_PREFETCH_HEADER,
4+
NEXT_ROUTER_SEGMENT_PREFETCH_HEADER,
5+
NEXT_ROUTER_STATE_TREE_HEADER,
6+
NEXT_URL,
7+
} from '../app-router-headers'
8+
9+
describe('setCacheBustingSearchParam', () => {
10+
it('should append cache-busting search parameter to URL without existing search params', () => {
11+
const url = new URL('https://example.com/path')
12+
const headers = {
13+
[NEXT_ROUTER_PREFETCH_HEADER]: '1',
14+
[NEXT_ROUTER_SEGMENT_PREFETCH_HEADER]: 'segment-1',
15+
[NEXT_ROUTER_STATE_TREE_HEADER]: 'state-tree-1',
16+
} as const
17+
18+
setCacheBustingSearchParam(url, headers)
19+
expect(url.toString()).toMatch(
20+
/https:\/\/example\.com\/path\?_rsc=[a-z0-9]+$/
21+
)
22+
})
23+
24+
it('should append cache-busting search parameter to URL with existing search params', () => {
25+
const url = new URL('https://example.com/path?query=1')
26+
const headers = {
27+
[NEXT_ROUTER_PREFETCH_HEADER]: '1',
28+
[NEXT_ROUTER_SEGMENT_PREFETCH_HEADER]: 'segment-2',
29+
[NEXT_ROUTER_STATE_TREE_HEADER]: 'state-tree-2',
30+
} as const
31+
32+
setCacheBustingSearchParam(url, headers)
33+
expect(url.toString()).toMatch(
34+
/https:\/\/example\.com\/path\?query=1&_rsc=[a-z0-9]+$/
35+
)
36+
})
37+
38+
it('should generate unique cache key based on headers', () => {
39+
const url = new URL('https://example.com/path')
40+
const headers1 = {
41+
[NEXT_ROUTER_PREFETCH_HEADER]: '1',
42+
[NEXT_ROUTER_SEGMENT_PREFETCH_HEADER]: 'segment-3',
43+
[NEXT_ROUTER_STATE_TREE_HEADER]: 'state-tree-3',
44+
} as const
45+
46+
const headers2 = {
47+
[NEXT_ROUTER_PREFETCH_HEADER]: '1',
48+
[NEXT_ROUTER_SEGMENT_PREFETCH_HEADER]: 'segment-4',
49+
[NEXT_ROUTER_STATE_TREE_HEADER]: 'state-tree-4',
50+
[NEXT_URL]: 'https://example.com/next-url-2',
51+
} as const
52+
53+
setCacheBustingSearchParam(url, headers1)
54+
const url1String = url.toString()
55+
56+
setCacheBustingSearchParam(url, headers2)
57+
const url2String = url.toString()
58+
59+
expect(url1String).not.toEqual(url2String)
60+
})
61+
62+
it('should append cache-busting search parameter to URL with existing search params containing %20', () => {
63+
const url = new URL('https://example.com/path?query=apple%20watch')
64+
const headers = {
65+
[NEXT_ROUTER_PREFETCH_HEADER]: '1',
66+
[NEXT_ROUTER_SEGMENT_PREFETCH_HEADER]: 'segment-5',
67+
[NEXT_ROUTER_STATE_TREE_HEADER]: 'state-tree-5',
68+
[NEXT_URL]: 'https://example.com/next-url',
69+
} as const
70+
71+
setCacheBustingSearchParam(url, headers)
72+
73+
// Ensure %20 is not automatically decoded
74+
expect(url.toString()).toMatch(
75+
/https:\/\/example\.com\/path\?query=apple%20watch&_rsc=[a-z0-9]+$/
76+
)
77+
})
78+
})
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
'use client'
2+
import { hexHash } from '../../../shared/lib/hash'
3+
import {
4+
NEXT_ROUTER_PREFETCH_HEADER,
5+
NEXT_ROUTER_SEGMENT_PREFETCH_HEADER,
6+
NEXT_ROUTER_STATE_TREE_HEADER,
7+
NEXT_URL,
8+
NEXT_RSC_UNION_QUERY,
9+
} from '../app-router-headers'
10+
import type { RequestHeaders } from './fetch-server-response'
11+
12+
/**
13+
* Mutates the provided URL by adding a cache-busting search parameter for CDNs that don't
14+
* support custom headers. This helps avoid caching conflicts by making each request unique.
15+
*
16+
* Rather than relying on the Vary header which some CDNs ignore, we append a search param
17+
* to create a unique URL that forces a fresh request.
18+
*
19+
* Example:
20+
* URL before: https://example.com/path?query=1
21+
* URL after: https://example.com/path?query=1&_rsc=abc123
22+
*
23+
* Note: This function mutates the input URL directly and does not return anything.
24+
*
25+
* TODO: Since we need to use a search param anyway, we could simplify by removing the custom
26+
* headers approach entirely and just use search params.
27+
*/
28+
export const setCacheBustingSearchParam = (
29+
url: URL,
30+
headers: RequestHeaders
31+
): void => {
32+
const uniqueCacheKey = hexHash(
33+
[
34+
headers[NEXT_ROUTER_PREFETCH_HEADER] || '0',
35+
headers[NEXT_ROUTER_SEGMENT_PREFETCH_HEADER] || '0',
36+
headers[NEXT_ROUTER_STATE_TREE_HEADER],
37+
headers[NEXT_URL],
38+
].join(',')
39+
)
40+
41+
/**
42+
* Note that we intentionally do not use `url.searchParams.set` here:
43+
*
44+
* const url = new URL('https://example.com/search?q=custom%20spacing');
45+
* url.searchParams.set('_rsc', 'abc123');
46+
* console.log(url.toString()); // Outputs: https://example.com/search?q=custom+spacing&_rsc=abc123
47+
* ^ <--- this is causing confusion
48+
* This is in fact intended based on https://url.spec.whatwg.org/#interface-urlsearchparams, but
49+
* we want to preserve the %20 as %20 if that's what the user passed in, hence the custom
50+
* logic below.
51+
*/
52+
const existingSearch = url.search
53+
const rawQuery = existingSearch.startsWith('?')
54+
? existingSearch.slice(1)
55+
: existingSearch
56+
const pairs = rawQuery.split('&').filter(Boolean)
57+
pairs.push(`${NEXT_RSC_UNION_QUERY}=${uniqueCacheKey}`)
58+
url.search = pairs.length ? `?${pairs.join('&')}` : ''
59+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import Link from 'next/link'
2+
3+
export default async function Page() {
4+
return (
5+
<>
6+
<p id="uri-encoded-prefetch">URI Encoded Prefetch</p>
7+
<p>
8+
<Link
9+
href="/?param=with%20space"
10+
id="prefetch-via-link"
11+
prefetch={true}
12+
>
13+
Prefetch Via Link
14+
</Link>
15+
</p>
16+
</>
17+
)
18+
}

test/e2e/app-dir/app-prefetch/prefetching.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,48 @@ describe('app dir - prefetching', () => {
342342
await browser.waitForElementByCss('#prefetch-auto-page-data')
343343
})
344344

345+
it('should not unintentionally modify the requested prefetch by escaping the uri encoded query params', async () => {
346+
const rscRequests = []
347+
const browser = await next.browser('/uri-encoded-prefetch', {
348+
beforePageLoad(page: Page) {
349+
page.on('request', async (req: Request) => {
350+
const url = new URL(req.url())
351+
const headers = await req.allHeaders()
352+
if (headers['rsc']) {
353+
rscRequests.push(url.pathname + url.search)
354+
}
355+
})
356+
},
357+
})
358+
359+
// sanity check: the link should be present
360+
expect(await browser.elementById('prefetch-via-link')).toBeDefined()
361+
362+
await browser.waitForIdleNetwork()
363+
364+
// The space encoding of the prefetch request should be the same as the href, and should not be replaced with a +
365+
await retry(async () => {
366+
expect(
367+
rscRequests.filter((req) => req.includes('/?param=with%20space'))
368+
).toHaveLength(1)
369+
})
370+
371+
// Click the link
372+
await browser.elementById('prefetch-via-link').click()
373+
374+
// Assert that we're on the homepage
375+
expect(await browser.hasElementByCssSelector('#to-dashboard')).toBe(true)
376+
377+
await browser.waitForIdleNetwork()
378+
379+
// No new requests should be made since it is correctly prefetched
380+
await retry(async () => {
381+
expect(
382+
rscRequests.filter((req) => req.includes('/?param=with%20space'))
383+
).toHaveLength(1)
384+
})
385+
})
386+
345387
describe('prefetch cache seeding', () => {
346388
it('should not re-fetch the initial static page if the same page is prefetched with prefetch={true}', async () => {
347389
const rscRequests = []

0 commit comments

Comments
 (0)