From 07165e498a579c105eb05d877a4bec4bff672fb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Best?= Date: Thu, 9 Jan 2025 20:46:34 +0100 Subject: [PATCH 1/4] feat: Key isolation in the React SPA adapter Key isolation is the fact that updating a search param only re-renders hooks subscribed to this key. Most frameworks (Next.js & React Router) re-render every call site of their useSearchParams hooks when any search params change, causing unnecessary re-renders. Since in the React SPA we control the whole thing (there is no router so to speak), we can implement caching to detect only relevant differences and avoid re-rendering unnecessarily. --- .../cypress/e2e/shared/key-isolation.cy.ts | 11 +++ packages/e2e/react/src/routes.tsx | 2 + .../routes/key-isolation.useQueryState.tsx | 3 + .../routes/key-isolation.useQueryStates.tsx | 3 + .../e2e/shared/cypress/support/log-spy.ts | 13 ++++ packages/e2e/shared/specs/key-isolation.cy.ts | 34 ++++++++++ packages/e2e/shared/specs/key-isolation.tsx | 51 ++++++++++++++ packages/e2e/shared/specs/render-count.cy.ts | 20 +----- packages/nuqs/src/adapters/lib/context.ts | 4 +- packages/nuqs/src/adapters/lib/defs.ts | 2 +- packages/nuqs/src/adapters/react.ts | 68 ++++++++++++++++--- packages/nuqs/src/useQueryState.ts | 2 +- packages/nuqs/src/useQueryStates.ts | 2 +- 13 files changed, 184 insertions(+), 31 deletions(-) create mode 100644 packages/e2e/react/cypress/e2e/shared/key-isolation.cy.ts create mode 100644 packages/e2e/react/src/routes/key-isolation.useQueryState.tsx create mode 100644 packages/e2e/react/src/routes/key-isolation.useQueryStates.tsx create mode 100644 packages/e2e/shared/cypress/support/log-spy.ts create mode 100644 packages/e2e/shared/specs/key-isolation.cy.ts create mode 100644 packages/e2e/shared/specs/key-isolation.tsx diff --git a/packages/e2e/react/cypress/e2e/shared/key-isolation.cy.ts b/packages/e2e/react/cypress/e2e/shared/key-isolation.cy.ts new file mode 100644 index 00000000..6385b64c --- /dev/null +++ b/packages/e2e/react/cypress/e2e/shared/key-isolation.cy.ts @@ -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' +}) diff --git a/packages/e2e/react/src/routes.tsx b/packages/e2e/react/src/routes.tsx index 3cd6f61f..63ce04cd 100644 --- a/packages/e2e/react/src/routes.tsx +++ b/packages/e2e/react/src/routes.tsx @@ -21,6 +21,8 @@ const routes: Record JSX.Element>> = { '/form/useQueryStates': lazy(() => import('./routes/form.useQueryStates')), '/referential-stability/useQueryState': lazy(() => import('./routes/referential-stability.useQueryState')), '/referential-stability/useQueryStates': lazy(() => import('./routes/referential-stability.useQueryStates')), + '/key-isolation/useQueryState': lazy(() => import('./routes/key-isolation.useQueryState')), + '/key-isolation/useQueryStates': lazy(() => import('./routes/key-isolation.useQueryStates')), '/render-count/useQueryState/true/replace/false': lazy(() => import('./routes/render-count')), '/render-count/useQueryState/true/replace/true': lazy(() => import('./routes/render-count')), diff --git a/packages/e2e/react/src/routes/key-isolation.useQueryState.tsx b/packages/e2e/react/src/routes/key-isolation.useQueryState.tsx new file mode 100644 index 00000000..db8b369a --- /dev/null +++ b/packages/e2e/react/src/routes/key-isolation.useQueryState.tsx @@ -0,0 +1,3 @@ +import { KeyIsolationUseQueryState } from 'e2e-shared/specs/key-isolation' + +export default KeyIsolationUseQueryState diff --git a/packages/e2e/react/src/routes/key-isolation.useQueryStates.tsx b/packages/e2e/react/src/routes/key-isolation.useQueryStates.tsx new file mode 100644 index 00000000..5822a894 --- /dev/null +++ b/packages/e2e/react/src/routes/key-isolation.useQueryStates.tsx @@ -0,0 +1,3 @@ +import { KeyIsolationUseQueryStates } from 'e2e-shared/specs/key-isolation' + +export default KeyIsolationUseQueryStates diff --git a/packages/e2e/shared/cypress/support/log-spy.ts b/packages/e2e/shared/cypress/support/log-spy.ts new file mode 100644 index 00000000..c4003e9d --- /dev/null +++ b/packages/e2e/shared/cypress/support/log-spy.ts @@ -0,0 +1,13 @@ +export const stubConsoleLog = { + onBeforeLoad(window: any) { + cy.stub(window.console, 'log').as('consoleLog') + } +} + +export function assertLogCount(message: string, expectedCount: number) { + cy.get('@consoleLog').then(spy => { + // @ts-ignore + const matchingLogs = spy.args.filter(args => args[0] === message) + expect(matchingLogs.length).to.equal(expectedCount) + }) +} diff --git a/packages/e2e/shared/specs/key-isolation.cy.ts b/packages/e2e/shared/specs/key-isolation.cy.ts new file mode 100644 index 00000000..33f9960b --- /dev/null +++ b/packages/e2e/shared/specs/key-isolation.cy.ts @@ -0,0 +1,34 @@ +import { createTest } from '../create-test' +import { assertLogCount, stubConsoleLog } from '../cypress/support/log-spy' + +export const testKeyIsolation = createTest('Key isolation', ({ path }) => { + it('does not render b when updating a', () => { + cy.visit(path, stubConsoleLog) + cy.contains('#hydration-marker', 'hydrated').should('be.hidden') + cy.get('#trigger-a').click() + cy.get('#state-a').should('have.text', 'pass') + cy.location('search').should('eq', '?a=pass') + assertLogCount('render a', 3) // 1 at mount + 2 at update + assertLogCount('render b', 1) // only 1 at mount + }) + it('does not render a when updating b', () => { + cy.visit(path, stubConsoleLog) + cy.contains('#hydration-marker', 'hydrated').should('be.hidden') + cy.get('#trigger-b').click() + cy.get('#state-b').should('have.text', 'pass') + cy.location('search').should('eq', '?b=pass') + assertLogCount('render b', 3) // 1 at mount + 2 at update + assertLogCount('render a', 1) // only 1 at mount + }) + it('does not render a again when updating b after a', () => { + cy.visit(path, stubConsoleLog) + cy.contains('#hydration-marker', 'hydrated').should('be.hidden') + cy.get('#trigger-a').click() + cy.get('#state-a').should('have.text', 'pass') + cy.get('#trigger-b').click() + cy.get('#state-b').should('have.text', 'pass') + cy.location('search').should('eq', '?a=pass&b=pass') + assertLogCount('render a', 3) // 1 at mount + 2 at update + assertLogCount('render b', 3) // 1 at mount + 2 at update + }) +}) diff --git a/packages/e2e/shared/specs/key-isolation.tsx b/packages/e2e/shared/specs/key-isolation.tsx new file mode 100644 index 00000000..27d4c143 --- /dev/null +++ b/packages/e2e/shared/specs/key-isolation.tsx @@ -0,0 +1,51 @@ +import { parseAsString, useQueryState, useQueryStates } from 'nuqs' + +type TestComponentProps = { + id: string +} + +export function KeyIsolationUseQueryState() { + return ( + <> + + + + ) +} + +export function KeyIsolationUseQueryStates() { + return ( + <> + + + + ) +} + +function TestComponentUseQueryState({ id }: TestComponentProps) { + const [state, setState] = useQueryState(id) + console.log(`render ${id}`) + return ( + <> + +
{state}
+ + ) +} + +function TestComponentUseQueryStates({ id }: TestComponentProps) { + const [state, setState] = useQueryStates({ + [id]: parseAsString + }) + console.log(`render ${id}`) + return ( + <> + +
{state[id]}
+ + ) +} diff --git a/packages/e2e/shared/specs/render-count.cy.ts b/packages/e2e/shared/specs/render-count.cy.ts index 1150d85c..5301a38a 100644 --- a/packages/e2e/shared/specs/render-count.cy.ts +++ b/packages/e2e/shared/specs/render-count.cy.ts @@ -1,4 +1,5 @@ import { createTest, type TestConfig } from '../create-test' +import { assertLogCount, stubConsoleLog } from '../cypress/support/log-spy' type TestRenderCountConfig = TestConfig & { props: { @@ -13,20 +14,6 @@ type TestRenderCountConfig = TestConfig & { } } -const stubConsoleLog = { - onBeforeLoad(window: any) { - cy.stub(window.console, 'log').as('consoleLog') - } -} - -function assertLogCount(message: string, expectedCount: number) { - cy.get('@consoleLog').then(spy => { - // @ts-ignore - const matchingLogs = spy.args.filter(args => args[0] === message) - expect(matchingLogs.length).to.equal(expectedCount) - }) -} - export function testRenderCount({ props, expected, @@ -60,12 +47,9 @@ export function testRenderCount({ cy.visit(path, stubConsoleLog) cy.contains('#hydration-marker', 'hydrated').should('be.hidden') cy.get('button').click() - if (props.delay) { - cy.wait(props.delay) - } - assertLogCount('render', expected.mount + expected.update) cy.get('#state').should('have.text', 'pass') cy.location('search').should('contain', 'test=pass') + assertLogCount('render', expected.mount + expected.update) } ) } diff --git a/packages/nuqs/src/adapters/lib/context.ts b/packages/nuqs/src/adapters/lib/context.ts index 034fa773..6237f48a 100644 --- a/packages/nuqs/src/adapters/lib/context.ts +++ b/packages/nuqs/src/adapters/lib/context.ts @@ -30,10 +30,10 @@ export function createAdapterProvider(useAdapter: UseAdapterHook) { ) } -export function useAdapter() { +export function useAdapter(watchKeys: string[]) { const value = useContext(context) if (!('useAdapter' in value)) { throw new Error(error(404)) } - return value.useAdapter() + return value.useAdapter(watchKeys) } diff --git a/packages/nuqs/src/adapters/lib/defs.ts b/packages/nuqs/src/adapters/lib/defs.ts index c1d3c1e4..f971fb57 100644 --- a/packages/nuqs/src/adapters/lib/defs.ts +++ b/packages/nuqs/src/adapters/lib/defs.ts @@ -7,7 +7,7 @@ export type UpdateUrlFunction = ( options: Required ) => void -export type UseAdapterHook = () => AdapterInterface +export type UseAdapterHook = (watchKeys: string[]) => AdapterInterface export type AdapterInterface = { searchParams: URLSearchParams diff --git a/packages/nuqs/src/adapters/react.ts b/packages/nuqs/src/adapters/react.ts index 33351b49..0105538f 100644 --- a/packages/nuqs/src/adapters/react.ts +++ b/packages/nuqs/src/adapters/react.ts @@ -1,9 +1,14 @@ import mitt from 'mitt' import { useEffect, useState } from 'react' +import { debug } from '../debug' import { renderQueryString } from '../url-encoding' import { createAdapterProvider } from './lib/context' import type { AdapterOptions } from './lib/defs' -import { patchHistory, type SearchParamsSyncEmitter } from './lib/patch-history' +import { + historyUpdateMarker, + patchHistory, + type SearchParamsSyncEmitter +} from './lib/patch-history' const emitter: SearchParamsSyncEmitter = mitt() @@ -12,30 +17,38 @@ function updateUrl(search: URLSearchParams, options: AdapterOptions) { url.search = renderQueryString(search) const method = options.history === 'push' ? history.pushState : history.replaceState - method.call(history, history.state, '', url) + method.call(history, history.state, historyUpdateMarker, url) emitter.emit('update', search) } -function useNuqsReactAdapter() { +function useNuqsReactAdapter(watchKeys: string[]) { const [searchParams, setSearchParams] = useState(() => { if (typeof location === 'undefined') { return new URLSearchParams() } - return new URLSearchParams(location.search) + const search = new URLSearchParams(location.search) + filterSearchParams(search, watchKeys) + return search }) useEffect(() => { // Popstate event is only fired when the user navigates // via the browser's back/forward buttons. const onPopState = () => { - setSearchParams(new URLSearchParams(location.search)) + setSearchParams( + applyChange(new URLSearchParams(location.search), watchKeys) + ) } - emitter.on('update', setSearchParams) + const onEmitterUpdate = (search: URLSearchParams) => { + setSearchParams(applyChange(search, watchKeys)) + } + emitter.on('update', onEmitterUpdate) window.addEventListener('popstate', onPopState) return () => { - emitter.off('update', setSearchParams) + emitter.off('update', onEmitterUpdate) window.removeEventListener('popstate', onPopState) } - }, []) + }, [watchKeys.join('&')]) + return { searchParams, updateUrl @@ -54,3 +67,42 @@ export const NuqsAdapter = createAdapterProvider(useNuqsReactAdapter) 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) + } + } +} diff --git a/packages/nuqs/src/useQueryState.ts b/packages/nuqs/src/useQueryState.ts index 06f11ad9..9b5f14f7 100644 --- a/packages/nuqs/src/useQueryState.ts +++ b/packages/nuqs/src/useQueryState.ts @@ -222,7 +222,7 @@ export function useQueryState( defaultValue: undefined } ) { - const adapter = useAdapter() + const adapter = useAdapter([key]) const initialSearchParams = adapter.searchParams const queryRef = useRef(initialSearchParams?.get(key) ?? null) const [internalState, setInternalState] = useState(() => { diff --git a/packages/nuqs/src/useQueryStates.ts b/packages/nuqs/src/useQueryStates.ts index 330958ae..96526ded 100644 --- a/packages/nuqs/src/useQueryStates.ts +++ b/packages/nuqs/src/useQueryStates.ts @@ -83,7 +83,7 @@ export function useQueryStates( ), [stateKeys, urlKeys] ) - const adapter = useAdapter() + const adapter = useAdapter(Object.values(resolvedUrlKeys)) const initialSearchParams = adapter.searchParams const queryRef = useRef>({}) // Initialise the queryRef with the initial values From ea9a91536c718d9b8e2623946c495d53aecc9584 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Best?= Date: Thu, 9 Jan 2025 21:05:06 +0100 Subject: [PATCH 2/4] test: Add wait for delay back --- packages/e2e/shared/specs/render-count.cy.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/e2e/shared/specs/render-count.cy.ts b/packages/e2e/shared/specs/render-count.cy.ts index 5301a38a..f77ed29d 100644 --- a/packages/e2e/shared/specs/render-count.cy.ts +++ b/packages/e2e/shared/specs/render-count.cy.ts @@ -47,6 +47,9 @@ export function testRenderCount({ cy.visit(path, stubConsoleLog) cy.contains('#hydration-marker', 'hydrated').should('be.hidden') cy.get('button').click() + if (props.delay) { + cy.wait(props.delay) + } cy.get('#state').should('have.text', 'pass') cy.location('search').should('contain', 'test=pass') assertLogCount('render', expected.mount + expected.update) From 054548e4691940c1ac9d7532f6ffa0794eb8ed57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Best?= Date: Fri, 10 Jan 2025 14:42:29 +0100 Subject: [PATCH 3/4] feat: Key isolation for React Router & Remix --- .../v6/cypress/e2e/shared/key-isolation.cy.ts | 11 ++++ .../e2e/react-router/v6/src/react-router.tsx | 2 + .../routes/key-isolation.useQueryState.tsx | 3 ++ .../routes/key-isolation.useQueryStates.tsx | 3 ++ packages/e2e/react-router/v7/app/routes.ts | 2 + .../routes/key-isolation.useQueryState.tsx | 3 ++ .../routes/key-isolation.useQueryStates.tsx | 3 ++ .../v7/cypress/e2e/shared/key-isolation.cy.ts | 11 ++++ .../routes/key-isolation.useQueryState.tsx | 10 ++++ .../routes/key-isolation.useQueryStates.tsx | 3 ++ .../cypress/e2e/shared/key-isolation.cy.ts | 11 ++++ .../nuqs/src/adapters/lib/key-isolation.ts | 49 +++++++++++++++++ .../nuqs/src/adapters/lib/react-router.ts | 22 +++++--- packages/nuqs/src/adapters/react.ts | 53 +++---------------- 14 files changed, 134 insertions(+), 52 deletions(-) create mode 100644 packages/e2e/react-router/v6/cypress/e2e/shared/key-isolation.cy.ts create mode 100644 packages/e2e/react-router/v6/src/routes/key-isolation.useQueryState.tsx create mode 100644 packages/e2e/react-router/v6/src/routes/key-isolation.useQueryStates.tsx create mode 100644 packages/e2e/react-router/v7/app/routes/key-isolation.useQueryState.tsx create mode 100644 packages/e2e/react-router/v7/app/routes/key-isolation.useQueryStates.tsx create mode 100644 packages/e2e/react-router/v7/cypress/e2e/shared/key-isolation.cy.ts create mode 100644 packages/e2e/remix/app/routes/key-isolation.useQueryState.tsx create mode 100644 packages/e2e/remix/app/routes/key-isolation.useQueryStates.tsx create mode 100644 packages/e2e/remix/cypress/e2e/shared/key-isolation.cy.ts create mode 100644 packages/nuqs/src/adapters/lib/key-isolation.ts diff --git a/packages/e2e/react-router/v6/cypress/e2e/shared/key-isolation.cy.ts b/packages/e2e/react-router/v6/cypress/e2e/shared/key-isolation.cy.ts new file mode 100644 index 00000000..6385b64c --- /dev/null +++ b/packages/e2e/react-router/v6/cypress/e2e/shared/key-isolation.cy.ts @@ -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' +}) diff --git a/packages/e2e/react-router/v6/src/react-router.tsx b/packages/e2e/react-router/v6/src/react-router.tsx index 702acce0..18edee4c 100644 --- a/packages/e2e/react-router/v6/src/react-router.tsx +++ b/packages/e2e/react-router/v6/src/react-router.tsx @@ -41,6 +41,8 @@ const router = createBrowserRouter( + + diff --git a/packages/e2e/react-router/v6/src/routes/key-isolation.useQueryState.tsx b/packages/e2e/react-router/v6/src/routes/key-isolation.useQueryState.tsx new file mode 100644 index 00000000..db8b369a --- /dev/null +++ b/packages/e2e/react-router/v6/src/routes/key-isolation.useQueryState.tsx @@ -0,0 +1,3 @@ +import { KeyIsolationUseQueryState } from 'e2e-shared/specs/key-isolation' + +export default KeyIsolationUseQueryState diff --git a/packages/e2e/react-router/v6/src/routes/key-isolation.useQueryStates.tsx b/packages/e2e/react-router/v6/src/routes/key-isolation.useQueryStates.tsx new file mode 100644 index 00000000..5822a894 --- /dev/null +++ b/packages/e2e/react-router/v6/src/routes/key-isolation.useQueryStates.tsx @@ -0,0 +1,3 @@ +import { KeyIsolationUseQueryStates } from 'e2e-shared/specs/key-isolation' + +export default KeyIsolationUseQueryStates diff --git a/packages/e2e/react-router/v7/app/routes.ts b/packages/e2e/react-router/v7/app/routes.ts index d55ccfbc..98e5d31d 100644 --- a/packages/e2e/react-router/v7/app/routes.ts +++ b/packages/e2e/react-router/v7/app/routes.ts @@ -24,6 +24,8 @@ export default [ route('/form/useQueryStates', './routes/form.useQueryStates.tsx'), route('/referential-stability/useQueryState', './routes/referential-stability.useQueryState.tsx'), route('/referential-stability/useQueryStates', './routes/referential-stability.useQueryStates.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'), route('/render-count/:hook/:shallow/:history/:startTransition/async-loader', './routes/render-count.$hook.$shallow.$history.$startTransition.async-loader.tsx'), diff --git a/packages/e2e/react-router/v7/app/routes/key-isolation.useQueryState.tsx b/packages/e2e/react-router/v7/app/routes/key-isolation.useQueryState.tsx new file mode 100644 index 00000000..db8b369a --- /dev/null +++ b/packages/e2e/react-router/v7/app/routes/key-isolation.useQueryState.tsx @@ -0,0 +1,3 @@ +import { KeyIsolationUseQueryState } from 'e2e-shared/specs/key-isolation' + +export default KeyIsolationUseQueryState diff --git a/packages/e2e/react-router/v7/app/routes/key-isolation.useQueryStates.tsx b/packages/e2e/react-router/v7/app/routes/key-isolation.useQueryStates.tsx new file mode 100644 index 00000000..5822a894 --- /dev/null +++ b/packages/e2e/react-router/v7/app/routes/key-isolation.useQueryStates.tsx @@ -0,0 +1,3 @@ +import { KeyIsolationUseQueryStates } from 'e2e-shared/specs/key-isolation' + +export default KeyIsolationUseQueryStates diff --git a/packages/e2e/react-router/v7/cypress/e2e/shared/key-isolation.cy.ts b/packages/e2e/react-router/v7/cypress/e2e/shared/key-isolation.cy.ts new file mode 100644 index 00000000..6385b64c --- /dev/null +++ b/packages/e2e/react-router/v7/cypress/e2e/shared/key-isolation.cy.ts @@ -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' +}) diff --git a/packages/e2e/remix/app/routes/key-isolation.useQueryState.tsx b/packages/e2e/remix/app/routes/key-isolation.useQueryState.tsx new file mode 100644 index 00000000..3be27900 --- /dev/null +++ b/packages/e2e/remix/app/routes/key-isolation.useQueryState.tsx @@ -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 diff --git a/packages/e2e/remix/app/routes/key-isolation.useQueryStates.tsx b/packages/e2e/remix/app/routes/key-isolation.useQueryStates.tsx new file mode 100644 index 00000000..5822a894 --- /dev/null +++ b/packages/e2e/remix/app/routes/key-isolation.useQueryStates.tsx @@ -0,0 +1,3 @@ +import { KeyIsolationUseQueryStates } from 'e2e-shared/specs/key-isolation' + +export default KeyIsolationUseQueryStates diff --git a/packages/e2e/remix/cypress/e2e/shared/key-isolation.cy.ts b/packages/e2e/remix/cypress/e2e/shared/key-isolation.cy.ts new file mode 100644 index 00000000..6385b64c --- /dev/null +++ b/packages/e2e/remix/cypress/e2e/shared/key-isolation.cy.ts @@ -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' +}) diff --git a/packages/nuqs/src/adapters/lib/key-isolation.ts b/packages/nuqs/src/adapters/lib/key-isolation.ts new file mode 100644 index 00000000..a3641228 --- /dev/null +++ b/packages/nuqs/src/adapters/lib/key-isolation.ts @@ -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 +} diff --git a/packages/nuqs/src/adapters/lib/react-router.ts b/packages/nuqs/src/adapters/lib/react-router.ts index 408b2df6..f15b481b 100644 --- a/packages/nuqs/src/adapters/lib/react-router.ts +++ b/packages/nuqs/src/adapters/lib/react-router.ts @@ -2,6 +2,7 @@ import mitt from 'mitt' import { startTransition, useCallback, useEffect, useState } from 'react' import { renderQueryString } from '../../url-encoding' import type { AdapterInterface, AdapterOptions } from './defs' +import { applyChange, filterSearchParams } from './key-isolation' import { historyUpdateMarker, patchHistory, @@ -20,7 +21,6 @@ type NavigateOptions = { } type NavigateFn = (url: NavigateUrl, options: NavigateOptions) => void type UseNavigate = () => NavigateFn - type UseSearchParams = () => [URLSearchParams, {}] // -- @@ -31,9 +31,11 @@ export function createReactRouterBasedAdapter( useSearchParams: UseSearchParams ) { 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(() => { @@ -77,15 +79,21 @@ export function createReactRouterBasedAdapter( updateUrl } } - function useOptimisticSearchParams() { + function useOptimisticSearchParams(watchKeys: string[] = []) { const [serverSearchParams] = useSearchParams() - const [searchParams, setSearchParams] = useState(serverSearchParams) + const [searchParams, setSearchParams] = useState(() => { + // Make a copy to avoid modifying the original search params + return filterSearchParams(serverSearchParams, watchKeys, true) + }) + 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) diff --git a/packages/nuqs/src/adapters/react.ts b/packages/nuqs/src/adapters/react.ts index 0105538f..ee82dcb3 100644 --- a/packages/nuqs/src/adapters/react.ts +++ b/packages/nuqs/src/adapters/react.ts @@ -1,9 +1,9 @@ import mitt from 'mitt' import { useEffect, useState } 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 { historyUpdateMarker, patchHistory, @@ -26,20 +26,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) @@ -67,42 +69,3 @@ export const NuqsAdapter = createAdapterProvider(useNuqsReactAdapter) 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) - } - } -} From 3202da119bc8da1ff0cee48495059b8357235edf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Best?= Date: Fri, 10 Jan 2025 15:41:27 +0100 Subject: [PATCH 4/4] chore: Remove test loader --- .../e2e/remix/app/routes/key-isolation.useQueryState.tsx | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/e2e/remix/app/routes/key-isolation.useQueryState.tsx b/packages/e2e/remix/app/routes/key-isolation.useQueryState.tsx index 3be27900..db8b369a 100644 --- a/packages/e2e/remix/app/routes/key-isolation.useQueryState.tsx +++ b/packages/e2e/remix/app/routes/key-isolation.useQueryState.tsx @@ -1,10 +1,3 @@ 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