Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Key isolation #855

Open
wants to merge 5 commits into
base: next
Choose a base branch
from
Open
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
feat: Key isolation for React Router & Remix
franky47 committed Mar 20, 2025
commit cdd04d4f85e73f0c3e9a71a5fa77453c0ebadb87
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { testKeyIsolation } from 'e2e-shared/specs/key-isolation.cy'

testKeyIsolation({
path: '/key-isolation/useQueryState',
hook: 'useQueryState'
})

testKeyIsolation({
path: '/key-isolation/useQueryStates',
hook: 'useQueryStates'
})
2 changes: 2 additions & 0 deletions packages/e2e/react-router/v6/src/react-router.tsx
Original file line number Diff line number Diff line change
@@ -49,6 +49,8 @@ const router = createBrowserRouter(
<Route path="pretty-urls" lazy={load(import('./routes/pretty-urls'))} />
<Route path="dynamic-segments/dynamic/:segment" lazy={load(import('./routes/dynamic-segments.dynamic.$segment'))} />
<Route path="dynamic-segments/catch-all?*" lazy={load(import('./routes/dynamic-segments.catch-all.$'))} />
<Route path="key-isolation/useQueryState" lazy={load(import('./routes/key-isolation.useQueryState'))} />
<Route path="key-isolation/useQueryStates" lazy={load(import('./routes/key-isolation.useQueryStates'))} />

<Route path="render-count/:hook/:shallow/:history/:startTransition/no-loader" lazy={load(import('./routes/render-count.$hook.$shallow.$history.$startTransition.no-loader'))} />
<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 number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { KeyIsolationUseQueryState } from 'e2e-shared/specs/key-isolation'

export default KeyIsolationUseQueryState
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { KeyIsolationUseQueryStates } from 'e2e-shared/specs/key-isolation'

export default KeyIsolationUseQueryStates
2 changes: 2 additions & 0 deletions packages/e2e/react-router/v7/app/routes.ts
Original file line number Diff line number Diff line change
@@ -32,6 +32,8 @@ export default [
route('/pretty-urls', './routes/pretty-urls.tsx'),
route('/dynamic-segments/dynamic/:segment', './routes/dynamic-segments.dynamic.$segment.tsx'),
route('/dynamic-segments/catch-all?/*', './routes/dynamic-segments.catch-all.$.tsx'),
route('/key-isolation/useQueryState', './routes/key-isolation.useQueryState.tsx'),
route('/key-isolation/useQueryStates', './routes/key-isolation.useQueryStates.tsx'),

route('/render-count/:hook/:shallow/:history/:startTransition/no-loader', './routes/render-count.$hook.$shallow.$history.$startTransition.no-loader.tsx'),
route('/render-count/:hook/:shallow/:history/:startTransition/sync-loader', './routes/render-count.$hook.$shallow.$history.$startTransition.sync-loader.tsx'),
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { KeyIsolationUseQueryState } from 'e2e-shared/specs/key-isolation'

export default KeyIsolationUseQueryState
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { KeyIsolationUseQueryStates } from 'e2e-shared/specs/key-isolation'

export default KeyIsolationUseQueryStates
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { testKeyIsolation } from 'e2e-shared/specs/key-isolation.cy'

testKeyIsolation({
path: '/key-isolation/useQueryState',
hook: 'useQueryState'
})

testKeyIsolation({
path: '/key-isolation/useQueryStates',
hook: 'useQueryStates'
})
10 changes: 10 additions & 0 deletions packages/e2e/remix/app/routes/key-isolation.useQueryState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { KeyIsolationUseQueryState } from 'e2e-shared/specs/key-isolation'

const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))

export async function loader() {
await wait(100)
return null
}

export default KeyIsolationUseQueryState
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { KeyIsolationUseQueryStates } from 'e2e-shared/specs/key-isolation'

export default KeyIsolationUseQueryStates
11 changes: 11 additions & 0 deletions packages/e2e/remix/cypress/e2e/shared/key-isolation.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { testKeyIsolation } from 'e2e-shared/specs/key-isolation.cy'

testKeyIsolation({
path: '/key-isolation/useQueryState',
hook: 'useQueryState'
})

testKeyIsolation({
path: '/key-isolation/useQueryStates',
hook: 'useQueryStates'
})
49 changes: 49 additions & 0 deletions packages/nuqs/src/adapters/lib/key-isolation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { debug } from '../../debug'

export function applyChange(
newValue: URLSearchParams,
keys: string[],
copy: boolean
) {
return (oldValue: URLSearchParams) => {
const hasChanged =
keys.length === 0
? true
: keys.some(key => oldValue.get(key) !== newValue.get(key))
if (!hasChanged) {
debug(
'[nuqs `%s`] no change, returning previous',
keys.join(','),
oldValue
)
return oldValue
}
const filtered = filterSearchParams(newValue, keys, copy)
debug(
`[nuqs \`%s\`] subbed search params change
from %O
to %O`,
keys.join(','),
oldValue,
filtered
)
return filtered
}
}

export function filterSearchParams(
search: URLSearchParams,
keys: string[],
copy: boolean
) {
if (keys.length === 0) {
return search
}
const filtered = copy ? new URLSearchParams(search) : search
for (const key of search.keys()) {
if (!keys.includes(key)) {
filtered.delete(key)
}
}
return filtered
}
34 changes: 21 additions & 13 deletions packages/nuqs/src/adapters/lib/react-router.ts
Original file line number Diff line number Diff line change
@@ -3,6 +3,7 @@ import { startTransition, useCallback, useEffect, useState } from 'react'
import { renderQueryString } from '../../url-encoding'
import { createAdapterProvider } from './context'
import type { AdapterInterface, AdapterOptions } from './defs'
import { applyChange, filterSearchParams } from './key-isolation'
import {
patchHistory as applyHistoryPatch,
historyUpdateMarker,
@@ -37,9 +38,11 @@ export function createReactRouterBasedAdapter({
useSearchParams
}: CreateReactRouterBasedAdapterArgs) {
const emitter: SearchParamsSyncEmitter = mitt()
function useNuqsReactRouterBasedAdapter(): AdapterInterface {
function useNuqsReactRouterBasedAdapter(
watchKeys: string[]
): AdapterInterface {
const navigate = useNavigate()
const searchParams = useOptimisticSearchParams()
const searchParams = useOptimisticSearchParams(watchKeys)
const updateUrl = useCallback(
(search: URLSearchParams, options: AdapterOptions) => {
startTransition(() => {
@@ -83,7 +86,7 @@ export function createReactRouterBasedAdapter({
updateUrl
}
}
function useOptimisticSearchParams() {
function useOptimisticSearchParams(watchKeys: string[] = []) {
const [serverSearchParams] = useSearchParams(
// Note: this will only be taken into account the first time the hook is called,
// and cached for subsequent calls, causing problems when mounting components
@@ -93,21 +96,26 @@ export function createReactRouterBasedAdapter({
: new URLSearchParams(location.search)
)
const [searchParams, setSearchParams] = useState(() => {
if (typeof location === 'undefined') {
// We use this on the server to SSR with the correct search params.
return serverSearchParams
}
// Since useSearchParams isn't reactive to shallow changes,
// it doesn't pick up changes in the URL on mount, so we need to initialise
// the reactive state with the current URL instead.
return new URLSearchParams(location.search)
return typeof location === 'undefined'
? // We use this on the server to SSR with the correct search params.
filterSearchParams(serverSearchParams, watchKeys, true)
: // Since useSearchParams isn't reactive to shallow changes,
// it doesn't pick up changes in the URL on mount, so we need to initialise
// the reactive state with the current URL instead.
filterSearchParams(
new URLSearchParams(location.search),
watchKeys,
false // No need for a copy here
)
})
useEffect(() => {
function onPopState() {
setSearchParams(new URLSearchParams(location.search))
setSearchParams(
applyChange(new URLSearchParams(location.search), watchKeys, false)
)
}
function onEmitterUpdate(search: URLSearchParams) {
setSearchParams(search)
setSearchParams(applyChange(search, watchKeys, true))
}
emitter.on('update', onEmitterUpdate)
window.addEventListener('popstate', onPopState)
53 changes: 8 additions & 45 deletions packages/nuqs/src/adapters/react.ts
Original file line number Diff line number Diff line change
@@ -8,10 +8,10 @@ import {
useState,
type ReactNode
} from 'react'
import { debug } from '../debug'
import { renderQueryString } from '../url-encoding'
import { createAdapterProvider } from './lib/context'
import type { AdapterOptions } from './lib/defs'
import { applyChange, filterSearchParams } from './lib/key-isolation'
import { patchHistory, type SearchParamsSyncEmitter } from './lib/patch-history'

const emitter: SearchParamsSyncEmitter = mitt()
@@ -48,20 +48,22 @@ function useNuqsReactAdapter(watchKeys: string[]) {
if (typeof location === 'undefined') {
return new URLSearchParams()
}
const search = new URLSearchParams(location.search)
filterSearchParams(search, watchKeys)
return search
return filterSearchParams(
new URLSearchParams(location.search),
watchKeys,
false
)
})
useEffect(() => {
// Popstate event is only fired when the user navigates
// via the browser's back/forward buttons.
const onPopState = () => {
setSearchParams(
applyChange(new URLSearchParams(location.search), watchKeys)
applyChange(new URLSearchParams(location.search), watchKeys, false)
)
}
const onEmitterUpdate = (search: URLSearchParams) => {
setSearchParams(applyChange(search, watchKeys))
setSearchParams(applyChange(search, watchKeys, true))
}
emitter.on('update', onEmitterUpdate)
window.addEventListener('popstate', onPopState)
@@ -106,42 +108,3 @@ export function NuqsAdapter({
export function enableHistorySync() {
patchHistory(emitter, 'react')
}

function applyChange(newValue: URLSearchParams, keys: string[]) {
return (oldValue: URLSearchParams) => {
const hasChanged =
keys.length === 0
? true
: keys.some(key => oldValue.get(key) !== newValue.get(key))
if (!hasChanged) {
debug(
'[nuqs `%s`] no change, returning previous',
keys.join(','),
oldValue
)
return oldValue
}
const copy = new URLSearchParams(newValue)
filterSearchParams(copy, keys)
debug(
`[nuqs \`%s\`] subbed search params change
from %O
to %O`,
keys.join(','),
oldValue,
copy
)
return copy
}
}

function filterSearchParams(search: URLSearchParams, keys: string[]) {
if (keys.length === 0) {
return
}
for (const key of search.keys()) {
if (!keys.includes(key)) {
search.delete(key)
}
}
}