mirror of
https://github.com/hanzoai/next.js.git
synced 2026-09-24 17:00:51 +00:00
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:
@@ -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)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+44
@@ -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
|
||||
Reference in New Issue
Block a user