Skip to content

Commit 9642846

Browse files
committedJun 26, 2024
fix: Deep equality check on cache parsing input
A change introduced in Next.js 15.0.0-canary.41 (precisely vercel/next.js#67105) broke the referential stability that the cache.parse method was expecting to detect `parse` being called with the same search params. Now we're using a deep equality check by caching the serialized query string that was parsed, and comparing it with the input when called again.
1 parent 9ec0ae3 commit 9642846

File tree

3 files changed

+42
-17
lines changed

3 files changed

+42
-17
lines changed
 

Diff for: ‎packages/nuqs/src/cache.test.ts

+23-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it, vi } from 'vitest'
2-
import { createSearchParamsCache } from './cache'
2+
import { createSearchParamsCache, stringify } from './cache'
33
import { parseAsString } from './parsers'
44

55
// provide a simple mock for React cache
@@ -22,7 +22,7 @@ describe('cache', () => {
2222
string: "I'm a string"
2323
}
2424

25-
it('allows parsing same object multiple times in a request', () => {
25+
it('allows parsing the same object multiple times in a request', () => {
2626
const cache = createSearchParamsCache({
2727
string: parseAsString
2828
})
@@ -37,6 +37,15 @@ describe('cache', () => {
3737
expect(cache.all()).toBe(all)
3838
})
3939

40+
it('allows parsing the same content with different references', () => {
41+
const cache = createSearchParamsCache({
42+
string: parseAsString
43+
})
44+
const copy = { ...input }
45+
expect(cache.parse(input).string).toBe(input.string)
46+
expect(cache.parse(copy).string).toBe(input.string)
47+
})
48+
4049
it('disallows parsing different objects in a request', () => {
4150
const cache = createSearchParamsCache({
4251
string: parseAsString
@@ -51,4 +60,16 @@ describe('cache', () => {
5160
expect(cache.all()).toBe(all)
5261
})
5362
})
63+
64+
describe('stringify', () => {
65+
it('works on string values', () => {
66+
expect(stringify({ foo: 'bar' })).toEqual('foo=bar')
67+
})
68+
it('works on array values', () => {
69+
expect(stringify({ foo: ['bar', 'baz'] })).toEqual('foo=bar%2Cbaz')
70+
})
71+
it('works on undefined values', () => {
72+
expect(stringify({ foo: undefined })).toEqual('foo=undefined')
73+
})
74+
})
5475
})

Diff for: ‎packages/nuqs/src/cache.ts

+18-14
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export function createSearchParamsCache<
2121

2222
type Cache = {
2323
searchParams: Partial<ParsedSearchParams>
24-
[$input]?: SearchParams
24+
[$input]?: string
2525
}
2626

2727
// Why not use a good old object here ?
@@ -34,28 +34,23 @@ export function createSearchParamsCache<
3434
}))
3535
function parse(searchParams: SearchParams) {
3636
const c = getCache()
37-
3837
if (Object.isFrozen(c.searchParams)) {
39-
// parse has already been called
40-
if (searchParams === c[$input]) {
41-
// but we're being called with the identical object again, so we can safely return the same cached result
42-
// (an example of when this occurs would be if parse was called in generateMetadata as well as the page itself).
43-
// note that this simply checks for referential equality and will still fail if a different object with the
44-
// same contents is passed. fortunately next.js uses the same object for search params in the same request.
38+
// Parse has already been called...
39+
if (stringify(searchParams) === c[$input]) {
40+
// ...but we're being called with the same contents again,
41+
// so we can safely return the same cached result (an example of when
42+
// this occurs would be if parse was called in generateMetadata as well
43+
// as the page itself).
4544
return all()
4645
}
47-
48-
// different input in the same request - fail
46+
// Different inputs in the same request - fail
4947
throw new Error(error(501))
5048
}
51-
5249
for (const key in parsers) {
5350
const parser = parsers[key]!
5451
c.searchParams[key] = parser.parseServerSide(searchParams[key])
5552
}
56-
57-
c[$input] = searchParams
58-
53+
c[$input] = stringify(searchParams)
5954
return Object.freeze(c.searchParams) as Readonly<ParsedSearchParams>
6055
}
6156
function all() {
@@ -80,3 +75,12 @@ export function createSearchParamsCache<
8075
}
8176
return { parse, get, all }
8277
}
78+
79+
/**
80+
* Internal function: reduce a SearchParams object to a string
81+
* for internal comparison of inputs passed to the cache's `parse` function.
82+
*/
83+
export function stringify(searchParams: SearchParams) {
84+
// @ts-expect-error - URLSearchParams is actually OK with array values
85+
return new URLSearchParams(searchParams).toString()
86+
}

Diff for: ‎packages/nuqs/src/index.server.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
export * from './cache'
1+
export { createSearchParamsCache, type SearchParams } from './cache'
22
export * from './parsers'
33
export { createSerializer } from './serializer'

0 commit comments

Comments
 (0)