fix: preserve repeated search params in client page segment cache keys (#94863)

Fixes: #94821, #92787, #93104

Using `Object.fromEntries` on URLSearchParams, drops repeated keys.

```js
const s = '?foo=bar&foo=baz'
Object.fromEntries(new URLSearchParams(s)) // { foo: 'baz' }
```

There's already a function, `urlSearchParamsToParsedUrlQuery`, which
handles this case.


https://github.com/vercel/next.js/blob/40296fe53f40ffe6a0e2184879bd5e294d271ce4/packages/next/src/client/route-params.ts#L235

```js
urlSearchParamsToParsedUrlQuery(new URLSearchParams(s)) // { foo: ['bar', 'baz'] }
```

- [x] The e2e off one of the issue reports, I'll try to simplify it.
This commit is contained in:
Joseph
2026-06-29 13:54:29 +02:00
committed by GitHub
parent 8a027d78eb
commit c3bf2a395a
7 changed files with 88 additions and 3 deletions
@@ -44,6 +44,7 @@ import {
import { FetchStrategy } from '../segment-cache/types'
import { discoverKnownRoute } from '../segment-cache/optimistic-routes'
import { NEXT_NAV_DEPLOYMENT_ID_HEADER } from '../../../lib/constants'
import { urlSearchParamsToParsedUrlQuery } from '../../route-params'
import type { NormalizedSearch } from '../segment-cache/cache-key'
import {
getRenderedSearchFromVaryPath,
@@ -778,7 +779,7 @@ function createSegmentFromRouteTree(newRouteTree: RouteTree): Segment {
// This is based on equivalent logic in addSearchParamsIfPageSegment, used
// on the server.
const stringifiedQuery = JSON.stringify(
Object.fromEntries(new URLSearchParams(renderedSearch))
urlSearchParamsToParsedUrlQuery(new URLSearchParams(renderedSearch))
)
return stringifiedQuery !== '{}'
? PAGE_SEGMENT_KEY + '?' + stringifiedQuery
@@ -31,6 +31,7 @@ import {
} from './cache'
import type { RouteCacheKey } from './cache-key'
import { createCacheKey } from './cache-key'
import { urlSearchParamsToParsedUrlQuery } from '../../route-params'
import {
FetchStrategy,
type PrefetchTaskFetchStrategy,
@@ -2046,7 +2047,9 @@ function doesCurrentSegmentMatchCachedSegment(
currentSegment ===
addSearchParamsIfPageSegment(
PAGE_SEGMENT_KEY,
Object.fromEntries(new URLSearchParams(route.renderedSearch))
urlSearchParamsToParsedUrlQuery(
new URLSearchParams(route.renderedSearch)
)
)
)
}
+1 -1
View File
@@ -186,7 +186,7 @@ export function getCacheKeyForDynamicParam(
// search string instead of turning it into JSON.
const pageSegmentWithSearchParams = addSearchParamsIfPageSegment(
paramValue,
Object.fromEntries(new URLSearchParams(renderedSearch))
urlSearchParamsToParsedUrlQuery(new URLSearchParams(renderedSearch))
) as string
return pageSegmentWithSearchParams
} else if (paramValue === null) {
@@ -0,0 +1,8 @@
import { ReactNode } from 'react'
export default function Root({ children }: { children: ReactNode }) {
return (
<html>
<body>{children}</body>
</html>
)
}
@@ -0,0 +1,23 @@
import Link from 'next/link'
import { Suspense } from 'react'
export default function Page({ searchParams }: PageProps<'/'>) {
return (
<main>
<Link id="to-bar" href="/?foo=bar">
foo=bar
</Link>
<Link id="to-bar-baz" href="/?foo=bar&foo=baz">
foo=bar&foo=baz
</Link>
<Link id="to-baz" href="/?foo=baz">
foo=baz
</Link>
<Suspense fallback={null}>
{searchParams.then((params) => (
<pre id="search-params">{JSON.stringify(params, null, 2)}</pre>
))}
</Suspense>
</main>
)
}
@@ -0,0 +1,44 @@
import { nextTestSetup } from 'e2e-utils'
import { retry } from 'next-test-utils'
// Regression test for multi-value (repeated key) search param transitions.
//
// Reproductions:
// - https://github.com/vercel/next.js/issues/94821 (next/form submit)
// - https://github.com/vercel/next.js/issues/92787 (<Link> navigation)
// - https://github.com/vercel/next.js/issues/93104 (router.replace)
//
// The client built the page segment cache key with
// `Object.fromEntries(new URLSearchParams(search))`, which only keeps the
// LAST value of a repeated key. So `?foo=bar&foo=baz` and `?foo=baz`
// collapsed to the same key, the multi -> single transition was treated as a
// cache hit, and the page never re-rendered with the new search params.
describe('multi-value-search-params-stale', () => {
const { next } = nextTestSetup({
files: __dirname,
})
it('re-renders when a repeated search param goes from multiple values to one', async () => {
const browser = await next.browser('/')
async function expectParams(expected: unknown) {
await retry(async () => {
const text = await browser.elementById('search-params').text()
expect(JSON.parse(text)).toEqual(expected)
})
}
await expectParams({})
await browser.elementById('to-bar').click()
await expectParams({ foo: 'bar' })
await browser.elementById('to-bar-baz').click()
await expectParams({ foo: ['bar', 'baz'] })
// The buggy transition: multiple values -> a single value.
await browser.elementById('to-baz').click()
await expectParams({ foo: 'baz' })
expect(new URL(await browser.url()).search).toBe('?foo=baz')
})
})
@@ -0,0 +1,6 @@
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {}
module.exports = nextConfig