Skip to content

Commit cdd04d4

Browse files
committed
feat: Key isolation for React Router & Remix
1 parent 7129c94 commit cdd04d4

14 files changed

+140
-58
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { testKeyIsolation } from 'e2e-shared/specs/key-isolation.cy'
2+
3+
testKeyIsolation({
4+
path: '/key-isolation/useQueryState',
5+
hook: 'useQueryState'
6+
})
7+
8+
testKeyIsolation({
9+
path: '/key-isolation/useQueryStates',
10+
hook: 'useQueryStates'
11+
})

packages/e2e/react-router/v6/src/react-router.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ const router = createBrowserRouter(
4949
<Route path="pretty-urls" lazy={load(import('./routes/pretty-urls'))} />
5050
<Route path="dynamic-segments/dynamic/:segment" lazy={load(import('./routes/dynamic-segments.dynamic.$segment'))} />
5151
<Route path="dynamic-segments/catch-all?*" lazy={load(import('./routes/dynamic-segments.catch-all.$'))} />
52+
<Route path="key-isolation/useQueryState" lazy={load(import('./routes/key-isolation.useQueryState'))} />
53+
<Route path="key-isolation/useQueryStates" lazy={load(import('./routes/key-isolation.useQueryStates'))} />
5254

5355
<Route path="render-count/:hook/:shallow/:history/:startTransition/no-loader" lazy={load(import('./routes/render-count.$hook.$shallow.$history.$startTransition.no-loader'))} />
5456
<Route path="render-count/:hook/:shallow/:history/:startTransition/sync-loader" lazy={load(import('./routes/render-count.$hook.$shallow.$history.$startTransition.sync-loader'))} />
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { KeyIsolationUseQueryState } from 'e2e-shared/specs/key-isolation'
2+
3+
export default KeyIsolationUseQueryState
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { KeyIsolationUseQueryStates } from 'e2e-shared/specs/key-isolation'
2+
3+
export default KeyIsolationUseQueryStates

packages/e2e/react-router/v7/app/routes.ts

+2
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ export default [
3232
route('/pretty-urls', './routes/pretty-urls.tsx'),
3333
route('/dynamic-segments/dynamic/:segment', './routes/dynamic-segments.dynamic.$segment.tsx'),
3434
route('/dynamic-segments/catch-all?/*', './routes/dynamic-segments.catch-all.$.tsx'),
35+
route('/key-isolation/useQueryState', './routes/key-isolation.useQueryState.tsx'),
36+
route('/key-isolation/useQueryStates', './routes/key-isolation.useQueryStates.tsx'),
3537

3638
route('/render-count/:hook/:shallow/:history/:startTransition/no-loader', './routes/render-count.$hook.$shallow.$history.$startTransition.no-loader.tsx'),
3739
route('/render-count/:hook/:shallow/:history/:startTransition/sync-loader', './routes/render-count.$hook.$shallow.$history.$startTransition.sync-loader.tsx'),
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { KeyIsolationUseQueryState } from 'e2e-shared/specs/key-isolation'
2+
3+
export default KeyIsolationUseQueryState
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { KeyIsolationUseQueryStates } from 'e2e-shared/specs/key-isolation'
2+
3+
export default KeyIsolationUseQueryStates
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { testKeyIsolation } from 'e2e-shared/specs/key-isolation.cy'
2+
3+
testKeyIsolation({
4+
path: '/key-isolation/useQueryState',
5+
hook: 'useQueryState'
6+
})
7+
8+
testKeyIsolation({
9+
path: '/key-isolation/useQueryStates',
10+
hook: 'useQueryStates'
11+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { KeyIsolationUseQueryState } from 'e2e-shared/specs/key-isolation'
2+
3+
const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
4+
5+
export async function loader() {
6+
await wait(100)
7+
return null
8+
}
9+
10+
export default KeyIsolationUseQueryState
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { KeyIsolationUseQueryStates } from 'e2e-shared/specs/key-isolation'
2+
3+
export default KeyIsolationUseQueryStates
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { testKeyIsolation } from 'e2e-shared/specs/key-isolation.cy'
2+
3+
testKeyIsolation({
4+
path: '/key-isolation/useQueryState',
5+
hook: 'useQueryState'
6+
})
7+
8+
testKeyIsolation({
9+
path: '/key-isolation/useQueryStates',
10+
hook: 'useQueryStates'
11+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { debug } from '../../debug'
2+
3+
export function applyChange(
4+
newValue: URLSearchParams,
5+
keys: string[],
6+
copy: boolean
7+
) {
8+
return (oldValue: URLSearchParams) => {
9+
const hasChanged =
10+
keys.length === 0
11+
? true
12+
: keys.some(key => oldValue.get(key) !== newValue.get(key))
13+
if (!hasChanged) {
14+
debug(
15+
'[nuqs `%s`] no change, returning previous',
16+
keys.join(','),
17+
oldValue
18+
)
19+
return oldValue
20+
}
21+
const filtered = filterSearchParams(newValue, keys, copy)
22+
debug(
23+
`[nuqs \`%s\`] subbed search params change
24+
from %O
25+
to %O`,
26+
keys.join(','),
27+
oldValue,
28+
filtered
29+
)
30+
return filtered
31+
}
32+
}
33+
34+
export function filterSearchParams(
35+
search: URLSearchParams,
36+
keys: string[],
37+
copy: boolean
38+
) {
39+
if (keys.length === 0) {
40+
return search
41+
}
42+
const filtered = copy ? new URLSearchParams(search) : search
43+
for (const key of search.keys()) {
44+
if (!keys.includes(key)) {
45+
filtered.delete(key)
46+
}
47+
}
48+
return filtered
49+
}

packages/nuqs/src/adapters/lib/react-router.ts

+21-13
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { startTransition, useCallback, useEffect, useState } from 'react'
33
import { renderQueryString } from '../../url-encoding'
44
import { createAdapterProvider } from './context'
55
import type { AdapterInterface, AdapterOptions } from './defs'
6+
import { applyChange, filterSearchParams } from './key-isolation'
67
import {
78
patchHistory as applyHistoryPatch,
89
historyUpdateMarker,
@@ -37,9 +38,11 @@ export function createReactRouterBasedAdapter({
3738
useSearchParams
3839
}: CreateReactRouterBasedAdapterArgs) {
3940
const emitter: SearchParamsSyncEmitter = mitt()
40-
function useNuqsReactRouterBasedAdapter(): AdapterInterface {
41+
function useNuqsReactRouterBasedAdapter(
42+
watchKeys: string[]
43+
): AdapterInterface {
4144
const navigate = useNavigate()
42-
const searchParams = useOptimisticSearchParams()
45+
const searchParams = useOptimisticSearchParams(watchKeys)
4346
const updateUrl = useCallback(
4447
(search: URLSearchParams, options: AdapterOptions) => {
4548
startTransition(() => {
@@ -83,7 +86,7 @@ export function createReactRouterBasedAdapter({
8386
updateUrl
8487
}
8588
}
86-
function useOptimisticSearchParams() {
89+
function useOptimisticSearchParams(watchKeys: string[] = []) {
8790
const [serverSearchParams] = useSearchParams(
8891
// Note: this will only be taken into account the first time the hook is called,
8992
// and cached for subsequent calls, causing problems when mounting components
@@ -93,21 +96,26 @@ export function createReactRouterBasedAdapter({
9396
: new URLSearchParams(location.search)
9497
)
9598
const [searchParams, setSearchParams] = useState(() => {
96-
if (typeof location === 'undefined') {
97-
// We use this on the server to SSR with the correct search params.
98-
return serverSearchParams
99-
}
100-
// Since useSearchParams isn't reactive to shallow changes,
101-
// it doesn't pick up changes in the URL on mount, so we need to initialise
102-
// the reactive state with the current URL instead.
103-
return new URLSearchParams(location.search)
99+
return typeof location === 'undefined'
100+
? // We use this on the server to SSR with the correct search params.
101+
filterSearchParams(serverSearchParams, watchKeys, true)
102+
: // Since useSearchParams isn't reactive to shallow changes,
103+
// it doesn't pick up changes in the URL on mount, so we need to initialise
104+
// the reactive state with the current URL instead.
105+
filterSearchParams(
106+
new URLSearchParams(location.search),
107+
watchKeys,
108+
false // No need for a copy here
109+
)
104110
})
105111
useEffect(() => {
106112
function onPopState() {
107-
setSearchParams(new URLSearchParams(location.search))
113+
setSearchParams(
114+
applyChange(new URLSearchParams(location.search), watchKeys, false)
115+
)
108116
}
109117
function onEmitterUpdate(search: URLSearchParams) {
110-
setSearchParams(search)
118+
setSearchParams(applyChange(search, watchKeys, true))
111119
}
112120
emitter.on('update', onEmitterUpdate)
113121
window.addEventListener('popstate', onPopState)

packages/nuqs/src/adapters/react.ts

+8-45
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@ import {
88
useState,
99
type ReactNode
1010
} from 'react'
11-
import { debug } from '../debug'
1211
import { renderQueryString } from '../url-encoding'
1312
import { createAdapterProvider } from './lib/context'
1413
import type { AdapterOptions } from './lib/defs'
14+
import { applyChange, filterSearchParams } from './lib/key-isolation'
1515
import { patchHistory, type SearchParamsSyncEmitter } from './lib/patch-history'
1616

1717
const emitter: SearchParamsSyncEmitter = mitt()
@@ -48,20 +48,22 @@ function useNuqsReactAdapter(watchKeys: string[]) {
4848
if (typeof location === 'undefined') {
4949
return new URLSearchParams()
5050
}
51-
const search = new URLSearchParams(location.search)
52-
filterSearchParams(search, watchKeys)
53-
return search
51+
return filterSearchParams(
52+
new URLSearchParams(location.search),
53+
watchKeys,
54+
false
55+
)
5456
})
5557
useEffect(() => {
5658
// Popstate event is only fired when the user navigates
5759
// via the browser's back/forward buttons.
5860
const onPopState = () => {
5961
setSearchParams(
60-
applyChange(new URLSearchParams(location.search), watchKeys)
62+
applyChange(new URLSearchParams(location.search), watchKeys, false)
6163
)
6264
}
6365
const onEmitterUpdate = (search: URLSearchParams) => {
64-
setSearchParams(applyChange(search, watchKeys))
66+
setSearchParams(applyChange(search, watchKeys, true))
6567
}
6668
emitter.on('update', onEmitterUpdate)
6769
window.addEventListener('popstate', onPopState)
@@ -106,42 +108,3 @@ export function NuqsAdapter({
106108
export function enableHistorySync() {
107109
patchHistory(emitter, 'react')
108110
}
109-
110-
function applyChange(newValue: URLSearchParams, keys: string[]) {
111-
return (oldValue: URLSearchParams) => {
112-
const hasChanged =
113-
keys.length === 0
114-
? true
115-
: keys.some(key => oldValue.get(key) !== newValue.get(key))
116-
if (!hasChanged) {
117-
debug(
118-
'[nuqs `%s`] no change, returning previous',
119-
keys.join(','),
120-
oldValue
121-
)
122-
return oldValue
123-
}
124-
const copy = new URLSearchParams(newValue)
125-
filterSearchParams(copy, keys)
126-
debug(
127-
`[nuqs \`%s\`] subbed search params change
128-
from %O
129-
to %O`,
130-
keys.join(','),
131-
oldValue,
132-
copy
133-
)
134-
return copy
135-
}
136-
}
137-
138-
function filterSearchParams(search: URLSearchParams, keys: string[]) {
139-
if (keys.length === 0) {
140-
return
141-
}
142-
for (const key of search.keys()) {
143-
if (!keys.includes(key)) {
144-
search.delete(key)
145-
}
146-
}
147-
}

0 commit comments

Comments
 (0)