Skip to content

Commit 9dc148f

Browse files
authored
fix: Cache the query string in useQueryStates (#631)
The cache key for testing if the query changed wasn't being updated in useQueryStates. This also revealed it being broken in next@14.0.3, where the history patching mechanism wasn't working. A fix has been added and will be removed in v2 (where support for those version ranges is dropped).
1 parent e65560b commit 9dc148f

File tree

3 files changed

+141
-0
lines changed

3 files changed

+141
-0
lines changed
+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/// <reference types="cypress" />
2+
3+
describe('Reproduction for issue #630', () => {
4+
it('works with useQueryState', () => {
5+
runTest('1')
6+
})
7+
it('works with useQueryStates', () => {
8+
runTest('3')
9+
})
10+
})
11+
12+
function runTest(sectionToTry) {
13+
cy.visit('/app/repro-630')
14+
cy.contains('#hydration-marker', 'hydrated').should('be.hidden')
15+
cy.get('#1-pre').should('have.text', '{"a":null,"b":null}')
16+
cy.get('#2-pre').should('have.text', '{"a":null,"b":null}')
17+
cy.get('#3-pre').should('have.text', '{"a":null,"b":null}')
18+
cy.get('#4-pre').should('have.text', '{"a":null,"b":null}')
19+
cy.get(`#${sectionToTry}-set`).click()
20+
cy.get('#1-pre').should('have.text', '{"a":"1","b":"2"}')
21+
cy.get('#2-pre').should('have.text', '{"a":"1","b":"2"}')
22+
cy.get('#3-pre').should('have.text', '{"a":"1","b":"2"}')
23+
cy.get('#4-pre').should('have.text', '{"a":"1","b":"2"}')
24+
cy.get(`#${sectionToTry}-clear`).click()
25+
cy.get('#1-pre').should('have.text', '{"a":null,"b":null}')
26+
cy.get('#2-pre').should('have.text', '{"a":null,"b":null}')
27+
cy.get('#3-pre').should('have.text', '{"a":null,"b":null}')
28+
cy.get('#4-pre').should('have.text', '{"a":null,"b":null}')
29+
cy.go('back')
30+
cy.get('#1-pre').should('have.text', '{"a":"1","b":"2"}')
31+
cy.get('#2-pre').should('have.text', '{"a":"1","b":"2"}')
32+
cy.get('#3-pre').should('have.text', '{"a":"1","b":"2"}')
33+
cy.get('#4-pre').should('have.text', '{"a":"1","b":"2"}')
34+
cy.go('back')
35+
cy.get('#1-pre').should('have.text', '{"a":null,"b":null}')
36+
cy.get('#2-pre').should('have.text', '{"a":null,"b":null}')
37+
cy.get('#3-pre').should('have.text', '{"a":null,"b":null}')
38+
cy.get('#4-pre').should('have.text', '{"a":null,"b":null}')
39+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
'use client'
2+
3+
import { parseAsString, useQueryState, useQueryStates } from 'nuqs'
4+
import { Suspense } from 'react'
5+
6+
export default function Page() {
7+
return (
8+
<Suspense>
9+
<Client id="1" />
10+
<Client id="2" />
11+
<Clients id="3" />
12+
<Clients id="4" />
13+
</Suspense>
14+
)
15+
}
16+
17+
type ClientProps = {
18+
id: string
19+
}
20+
21+
function Client({ id }: ClientProps) {
22+
const [a, setA] = useQueryState(
23+
'a',
24+
parseAsString.withOptions({ history: 'push' })
25+
)
26+
const [b, setB] = useQueryState(
27+
'b',
28+
parseAsString.withOptions({ history: 'push' })
29+
)
30+
31+
return (
32+
<>
33+
<p>useQueryState {id}</p>
34+
<pre id={`${id}-pre`}>{JSON.stringify({ a, b })}</pre>
35+
<button
36+
id={`${id}-set`}
37+
onClick={() => {
38+
setA('1')
39+
setB('2')
40+
}}
41+
>
42+
Set
43+
</button>
44+
<button
45+
id={`${id}-clear`}
46+
onClick={() => {
47+
setA(null)
48+
setB(null)
49+
}}
50+
>
51+
Clear
52+
</button>
53+
<hr />
54+
</>
55+
)
56+
}
57+
58+
function Clients({ id }: ClientProps) {
59+
const [params, setParams] = useQueryStates(
60+
{
61+
a: parseAsString,
62+
b: parseAsString
63+
},
64+
{ history: 'push' }
65+
)
66+
return (
67+
<>
68+
<p>useQueryStates {id}</p>
69+
<pre id={`${id}-pre`}>{JSON.stringify(params)}</pre>
70+
<button id={`${id}-set`} onClick={() => setParams({ a: '1', b: '2' })}>
71+
Set
72+
</button>
73+
<button id={`${id}-clear`} onClick={() => setParams(null)}>
74+
Clear
75+
</button>
76+
<hr />
77+
</>
78+
)
79+
}

packages/nuqs/src/useQueryStates.ts

+23
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,26 @@ export function useQueryStates<KeyMap extends UseQueryStatesKeysMap>(
8888
initialSearchParams
8989
)
9090

91+
React.useEffect(() => {
92+
// This will be removed in v2 which will drop support for
93+
// partially-functional shallow routing (14.0.2 and 14.0.3)
94+
if (window.next?.version !== '14.0.3') {
95+
return
96+
}
97+
const state = parseMap(
98+
keyMap,
99+
initialSearchParams,
100+
queryRef.current,
101+
stateRef.current
102+
)
103+
setInternalState(state)
104+
}, [
105+
Object.keys(keyMap)
106+
.map(key => initialSearchParams?.get(key))
107+
.join('&'),
108+
keys
109+
])
110+
91111
// Sync all hooks together & with external URL changes
92112
React.useInsertionEffect(() => {
93113
function updateInternalState(state: V) {
@@ -216,6 +236,9 @@ function parseMap<KeyMap extends UseQueryStatesKeysMap>(
216236
}
217237
const value = query === null ? null : safeParse(parse, query, key)
218238
obj[key as keyof KeyMap] = value ?? defaultValue ?? null
239+
if (cachedQuery) {
240+
cachedQuery[key] = query
241+
}
219242
return obj
220243
}, {} as Values<KeyMap>)
221244
}

0 commit comments

Comments
 (0)