From b0841e66dc3c1481c682f886cadd419f762d89cb Mon Sep 17 00:00:00 2001 From: Francois Best Date: Wed, 20 Nov 2024 20:26:20 +0100 Subject: [PATCH 01/23] feat: Add shallow routing support for Remix --- packages/e2e/remix/app/routes/ssr.tsx | 56 +++++++++++++++++++++++++++ packages/nuqs/src/adapters/remix.ts | 53 ++++++++++++++++++++----- 2 files changed, 99 insertions(+), 10 deletions(-) create mode 100644 packages/e2e/remix/app/routes/ssr.tsx diff --git a/packages/e2e/remix/app/routes/ssr.tsx b/packages/e2e/remix/app/routes/ssr.tsx new file mode 100644 index 000000000..15e13f5f1 --- /dev/null +++ b/packages/e2e/remix/app/routes/ssr.tsx @@ -0,0 +1,56 @@ +import { useSearchParams } from '@remix-run/react' +import { parseAsString, useQueryState } from 'nuqs' +import { useOptimisticSearchParams } from 'nuqs/adapters/remix' + +export async function loader({ request }: { request: Request }) { + if (URL.canParse(request.url)) { + console.log(new URL(request.url).search) + } + return null +} + +export default function Component() { + const [shallow, setShallow] = useQueryState( + 'shallow', + parseAsString.withDefault('') + ) + const [deep, setDeep] = useQueryState( + 'deep', + parseAsString.withDefault('').withOptions({ + shallow: false, + throttleMs: 100 + }) + ) + const [searchParams] = useSearchParams() + const optimistic = useOptimisticSearchParams() + return ( + <> + setShallow(e.target.value)} + className="block" + /> + setDeep(e.target.value)} + className="block" + /> +
+        Remix useSearchParams:
+        
+ {renderSearchParams(searchParams)} +
+
+        nuqs useOptimisticSearchParams:
+        
+ {renderSearchParams(optimistic)} +
+ + ) +} + +function renderSearchParams(searchParams: URLSearchParams) { + return JSON.stringify(Object.fromEntries(searchParams.entries()), null, 2) +} diff --git a/packages/nuqs/src/adapters/remix.ts b/packages/nuqs/src/adapters/remix.ts index 4598c3417..3a98aa5bb 100644 --- a/packages/nuqs/src/adapters/remix.ts +++ b/packages/nuqs/src/adapters/remix.ts @@ -1,22 +1,40 @@ import { useNavigate, useSearchParams } from '@remix-run/react' +import mitt from 'mitt' +import { startTransition, useEffect, useLayoutEffect, useState } from 'react' import { renderQueryString } from '../url-encoding' import type { AdapterOptions } from './defs' import { createAdapterProvider } from './internal.context' +const emitter = mitt<{ update: URLSearchParams }>() + function useNuqsRemixAdapter() { const navigate = useNavigate() - const [searchParams] = useSearchParams() + const searchParams = useOptimisticSearchParams() const updateUrl = (search: URLSearchParams, options: AdapterOptions) => { - navigate( - { - search: renderQueryString(search), - hash: location.hash - }, - { - replace: options.history === 'replace', - preventScrollReset: !options.scroll - } + startTransition(() => { + emitter.emit('update', search) + }) + const url = new URL(location.href) + url.search = renderQueryString(search) + // First, update the URL locally without triggering a network request, + // this allows keeping a reactive URL if the network is slow. + const updateMethod = + options.history === 'push' ? history.pushState : history.replaceState + updateMethod.call( + history, + history.state, // Maintain the history state + '', + url ) + if (options.scroll) { + window.scrollTo(0, 0) + } + if (!options.shallow) { + navigate(url, { + replace: true, + preventScrollReset: true + }) + } } return { searchParams, @@ -25,3 +43,18 @@ function useNuqsRemixAdapter() { } export const NuqsAdapter = createAdapterProvider(useNuqsRemixAdapter) + +export function useOptimisticSearchParams() { + const [serverSearchParams] = useSearchParams() + const [searchParams, setSearchParams] = useState(serverSearchParams) + useEffect(() => { + emitter.on('update', setSearchParams) + return () => { + emitter.off('update', setSearchParams) + } + }, []) + useLayoutEffect(() => { + emitter.emit('update', serverSearchParams) + }, [serverSearchParams]) + return searchParams +} From d7628124d562eec701f72c8d87f89fb62bafe1ca Mon Sep 17 00:00:00 2001 From: Francois Best Date: Sat, 7 Dec 2024 21:10:49 +0100 Subject: [PATCH 02/23] feat: Bring back history patching It's needed for frameworks that want to be reactive to any change in the search params, even coming from 1st/3rd party code calling the history API directly. --- packages/e2e/remix/app/root.tsx | 4 +- packages/nuqs/src/adapters/patch-history.ts | 80 +++++++++++++++++++++ packages/nuqs/src/adapters/remix.ts | 27 +++++-- packages/nuqs/src/errors.ts | 2 +- 4 files changed, 107 insertions(+), 6 deletions(-) create mode 100644 packages/nuqs/src/adapters/patch-history.ts diff --git a/packages/e2e/remix/app/root.tsx b/packages/e2e/remix/app/root.tsx index 9948b22f3..f35600e60 100644 --- a/packages/e2e/remix/app/root.tsx +++ b/packages/e2e/remix/app/root.tsx @@ -1,7 +1,9 @@ import { Links, Meta, Scripts, ScrollRestoration } from '@remix-run/react' -import { NuqsAdapter } from 'nuqs/adapters/remix' +import { enableHistorySync, NuqsAdapter } from 'nuqs/adapters/remix' import RootLayout from './layout' +enableHistorySync() + export function Layout({ children }: { children: React.ReactNode }) { return ( diff --git a/packages/nuqs/src/adapters/patch-history.ts b/packages/nuqs/src/adapters/patch-history.ts new file mode 100644 index 000000000..318c5c386 --- /dev/null +++ b/packages/nuqs/src/adapters/patch-history.ts @@ -0,0 +1,80 @@ +import type { Emitter } from 'mitt' +import { debug } from '../debug' +import { error } from '../errors' + +export type SearchParamsSyncEmitter = Emitter<{ update: URLSearchParams }> + +export const historyUpdateMarker = '__nuqs__' + +declare global { + interface History { + nuqs?: { + version: string + adapters: string[] + } + } +} + +export function patchHistory( + emitter: SearchParamsSyncEmitter, + adapter: string +) { + if (typeof history === 'undefined') { + return + } + if ( + history.nuqs?.version && + history.nuqs.version !== '0.0.0-inject-version-here' + ) { + console.error( + error(409), + history.nuqs.version, + `0.0.0-inject-version-here`, + adapter + ) + return + } + if (history.nuqs?.adapters?.includes(adapter)) { + return + } + debug( + '[nuqs %s] Patching history (%s adapter)', + '0.0.0-inject-version-here', + adapter + ) + function sync(url: URL | string) { + try { + if (url instanceof URL) { + return emitter.emit('update', url.searchParams) + } + if (URL.canParse(url)) { + emitter.emit('update', new URL(url).searchParams) + } else if (url.startsWith('?')) { + emitter.emit('update', new URLSearchParams(url)) + } + } catch (e) { + console.error(e) + } + } + const originalPushState = history.pushState + const originalReplaceState = history.replaceState + history.pushState = function nuqs_pushState(state, marker, url) { + originalPushState.call(history, state, '', url) + if (url && marker !== historyUpdateMarker) { + sync(url) + } + } + history.replaceState = function nuqs_replaceState(state, marker, url) { + originalReplaceState.call(history, state, '', url) + if (url && marker !== historyUpdateMarker) { + sync(url) + } + } + // Mark as patched + history.nuqs = history.nuqs ?? { + // This will be replaced by the prepack script + version: '0.0.0-inject-version-here', + adapters: [] + } + history.nuqs.adapters.push(adapter) +} diff --git a/packages/nuqs/src/adapters/remix.ts b/packages/nuqs/src/adapters/remix.ts index 3a98aa5bb..848282866 100644 --- a/packages/nuqs/src/adapters/remix.ts +++ b/packages/nuqs/src/adapters/remix.ts @@ -1,11 +1,19 @@ -import { useNavigate, useSearchParams } from '@remix-run/react' +import { + useNavigate, + useSearchParams as useRemixSearchParams +} from '@remix-run/react' import mitt from 'mitt' import { startTransition, useEffect, useLayoutEffect, useState } from 'react' import { renderQueryString } from '../url-encoding' import type { AdapterOptions } from './defs' import { createAdapterProvider } from './internal.context' +import { + historyUpdateMarker, + patchHistory, + type SearchParamsSyncEmitter +} from './patch-history' -const emitter = mitt<{ update: URLSearchParams }>() +const emitter: SearchParamsSyncEmitter = mitt() function useNuqsRemixAdapter() { const navigate = useNavigate() @@ -23,7 +31,7 @@ function useNuqsRemixAdapter() { updateMethod.call( history, history.state, // Maintain the history state - '', + historyUpdateMarker, url ) if (options.scroll) { @@ -45,7 +53,7 @@ function useNuqsRemixAdapter() { export const NuqsAdapter = createAdapterProvider(useNuqsRemixAdapter) export function useOptimisticSearchParams() { - const [serverSearchParams] = useSearchParams() + const [serverSearchParams] = useRemixSearchParams() const [searchParams, setSearchParams] = useState(serverSearchParams) useEffect(() => { emitter.on('update', setSearchParams) @@ -58,3 +66,14 @@ export function useOptimisticSearchParams() { }, [serverSearchParams]) return searchParams } + +/** + * Opt-in to syncing shallow updates of the URL with the useOptimisticSearchParams hook. + * + * By default, the useOptimisticSearchParams hook will only react to internal nuqs updates. + * If third party code updates the History API directly, use this function to + * enable useOptimisticSearchParams to react to those changes. + */ +export function enableHistorySync() { + patchHistory(emitter, 'remix') +} diff --git a/packages/nuqs/src/errors.ts b/packages/nuqs/src/errors.ts index 911cf1841..072794769 100644 --- a/packages/nuqs/src/errors.ts +++ b/packages/nuqs/src/errors.ts @@ -1,6 +1,6 @@ export const errors = { 404: 'nuqs requires an adapter to work with your framework.', - 409: 'Multiple versions of the library are loaded. This may lead to unexpected behavior. Currently using `%s`, but `%s` was about to load on top.', + 409: 'Multiple versions of the library are loaded. This may lead to unexpected behavior. Currently using `%s`, but `%s` (via the %s adapter) was about to load on top.', 414: 'Max safe URL length exceeded. Some browsers may not be able to accept this URL. Consider limiting the amount of state stored in the URL.', 429: 'URL update rate-limited by the browser. Consider increasing `throttleMs` for key(s) `%s`. %O', 500: "Empty search params cache. Search params can't be accessed in Layouts.", From 12c3e0daef4112cb37640019cfc600ebd4a68955 Mon Sep 17 00:00:00 2001 From: Francois Best Date: Mon, 16 Dec 2024 11:06:18 +0100 Subject: [PATCH 03/23] fix: Pick up popstate events (for history: push & back button) --- packages/nuqs/src/adapters/remix.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/nuqs/src/adapters/remix.ts b/packages/nuqs/src/adapters/remix.ts index 848282866..d0f01f65d 100644 --- a/packages/nuqs/src/adapters/remix.ts +++ b/packages/nuqs/src/adapters/remix.ts @@ -56,9 +56,14 @@ export function useOptimisticSearchParams() { const [serverSearchParams] = useRemixSearchParams() const [searchParams, setSearchParams] = useState(serverSearchParams) useEffect(() => { + function onPopState() { + setSearchParams(new URLSearchParams(location.search)) + } emitter.on('update', setSearchParams) + window.addEventListener('popstate', onPopState) return () => { emitter.off('update', setSearchParams) + window.removeEventListener('popstate', onPopState) } }, []) useLayoutEffect(() => { From 6dcfc1093bfa56bd997fdd210c687c26f89819b8 Mon Sep 17 00:00:00 2001 From: Francois Best Date: Mon, 16 Dec 2024 14:00:06 +0100 Subject: [PATCH 04/23] fix: History patching URL.canParse isn't available in Cypress, so we consider it unavailable in client code too, and replace it with a bespoke implementation that extracts search params from whatever can be fed to the history API. --- .../remix/cypress/e2e/shared/routing.cy.ts | 4 +-- packages/e2e/shared/cypress.config.ts | 5 ++- packages/e2e/shared/specs/routing.cy.ts | 2 +- .../nuqs/src/adapters/patch-history.test.ts | 31 +++++++++++++++++++ packages/nuqs/src/adapters/patch-history.ts | 23 +++++++++----- 5 files changed, 53 insertions(+), 12 deletions(-) create mode 100644 packages/nuqs/src/adapters/patch-history.test.ts diff --git a/packages/e2e/remix/cypress/e2e/shared/routing.cy.ts b/packages/e2e/remix/cypress/e2e/shared/routing.cy.ts index 7f84e065e..fbe0d65aa 100644 --- a/packages/e2e/remix/cypress/e2e/shared/routing.cy.ts +++ b/packages/e2e/remix/cypress/e2e/shared/routing.cy.ts @@ -3,11 +3,11 @@ import { testRouting } from 'e2e-shared/specs/routing.cy' testRouting({ path: '/routing/useQueryState', hook: 'useQueryState', - shallowOptions: [false] // todo: Enable shallow routing + shallowOptions: [true, false] }) testRouting({ path: '/routing/useQueryStates', hook: 'useQueryStates', - shallowOptions: [false] // todo: Enable shallow routing + shallowOptions: [true, false] }) diff --git a/packages/e2e/shared/cypress.config.ts b/packages/e2e/shared/cypress.config.ts index 49c8f8e28..a63ba8a0c 100644 --- a/packages/e2e/shared/cypress.config.ts +++ b/packages/e2e/shared/cypress.config.ts @@ -15,7 +15,10 @@ export function defineConfig(config: Config) { setupNodeEvents(on) { cypressTerminalReport(on) }, - retries: 2, + retries: { + openMode: 0, + runMode: 1 + }, ...config } }) diff --git a/packages/e2e/shared/specs/routing.cy.ts b/packages/e2e/shared/specs/routing.cy.ts index 1cada3f1b..41df1ab6b 100644 --- a/packages/e2e/shared/specs/routing.cy.ts +++ b/packages/e2e/shared/specs/routing.cy.ts @@ -26,7 +26,7 @@ export function testRouting({ } }) - it('picks up state from a router issued from another page', () => { + it(`picks up state from a router issued from another page - router.${method}({ shallow: ${shallow} })`, () => { cy.visit(getRoutingUrl(path + '/other', { shallow, method })) cy.contains('#hydration-marker', 'hydrated').should('be.hidden') cy.get('#state').should('be.empty') diff --git a/packages/nuqs/src/adapters/patch-history.test.ts b/packages/nuqs/src/adapters/patch-history.test.ts new file mode 100644 index 000000000..5eb88daa4 --- /dev/null +++ b/packages/nuqs/src/adapters/patch-history.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from 'vitest' +import { getSearchParams } from './patch-history' + +describe('patch-history/getSearchParams', () => { + it('extracts search params from a URL object', () => { + const received = getSearchParams(new URL('http://example.com/?foo=bar')) + const expected = new URLSearchParams('?foo=bar') + expect(received).toEqual(expected) + }) + it('extracts search params from a fully-qualified URL string', () => { + const received = getSearchParams('http://example.com/?foo=bar') + const expected = new URLSearchParams('?foo=bar') + expect(received).toEqual(expected) + }) + it('extracts search params from a pathname', () => { + vi.stubGlobal('location', { origin: 'http://example.com' }) + const received = getSearchParams('/?foo=bar') + const expected = new URLSearchParams('?foo=bar') + expect(received).toEqual(expected) + }) + it('extracts search params from a query string', () => { + const received = getSearchParams('?foo=bar') + const expected = new URLSearchParams('?foo=bar') + expect(received).toEqual(expected) + }) + it('falls back to an empty search params object for invalid inputs', () => { + const received = getSearchParams('invalid') + const expected = new URLSearchParams() + expect(received).toEqual(expected) + }) +}) diff --git a/packages/nuqs/src/adapters/patch-history.ts b/packages/nuqs/src/adapters/patch-history.ts index 318c5c386..c43328baf 100644 --- a/packages/nuqs/src/adapters/patch-history.ts +++ b/packages/nuqs/src/adapters/patch-history.ts @@ -15,6 +15,20 @@ declare global { } } +export function getSearchParams(url: string | URL) { + if (url instanceof URL) { + return url.searchParams + } + if (url.startsWith('?')) { + return new URLSearchParams(url) + } + try { + return new URL(url, location.origin).searchParams + } catch { + return new URLSearchParams(url) + } +} + export function patchHistory( emitter: SearchParamsSyncEmitter, adapter: string @@ -44,14 +58,7 @@ export function patchHistory( ) function sync(url: URL | string) { try { - if (url instanceof URL) { - return emitter.emit('update', url.searchParams) - } - if (URL.canParse(url)) { - emitter.emit('update', new URL(url).searchParams) - } else if (url.startsWith('?')) { - emitter.emit('update', new URLSearchParams(url)) - } + emitter.emit('update', getSearchParams(url)) } catch (e) { console.error(e) } From 8e471cb36d5153e667551230989a8cb9095d50c2 Mon Sep 17 00:00:00 2001 From: Francois Best Date: Mon, 16 Dec 2024 15:37:05 +0100 Subject: [PATCH 05/23] test: Add shallow e2e test for Next.js --- .../e2e/next/cypress/e2e/shared/shallow.cy.ts | 29 +++++++++++ .../(shared)/shallow/useQueryState/page.tsx | 35 +++++++++++++ .../(shared)/shallow/useQueryStates/page.tsx | 35 +++++++++++++ .../src/pages/pages/shallow/useQueryState.tsx | 26 ++++++++++ .../pages/pages/shallow/useQueryStates.tsx | 26 ++++++++++ packages/e2e/shared/specs/shallow.cy.ts | 51 +++++++++++++++++++ packages/e2e/shared/specs/shallow.defs.ts | 8 +++ packages/e2e/shared/specs/shallow.server.tsx | 8 +++ packages/e2e/shared/specs/shallow.tsx | 36 +++++++++++++ 9 files changed, 254 insertions(+) create mode 100644 packages/e2e/next/cypress/e2e/shared/shallow.cy.ts create mode 100644 packages/e2e/next/src/app/app/(shared)/shallow/useQueryState/page.tsx create mode 100644 packages/e2e/next/src/app/app/(shared)/shallow/useQueryStates/page.tsx create mode 100644 packages/e2e/next/src/pages/pages/shallow/useQueryState.tsx create mode 100644 packages/e2e/next/src/pages/pages/shallow/useQueryStates.tsx create mode 100644 packages/e2e/shared/specs/shallow.cy.ts create mode 100644 packages/e2e/shared/specs/shallow.defs.ts create mode 100644 packages/e2e/shared/specs/shallow.server.tsx create mode 100644 packages/e2e/shared/specs/shallow.tsx diff --git a/packages/e2e/next/cypress/e2e/shared/shallow.cy.ts b/packages/e2e/next/cypress/e2e/shared/shallow.cy.ts new file mode 100644 index 000000000..02f30b626 --- /dev/null +++ b/packages/e2e/next/cypress/e2e/shared/shallow.cy.ts @@ -0,0 +1,29 @@ +import { testShallow } from 'e2e-shared/specs/shallow.cy' + +testShallow({ + path: '/app/shallow/useQueryState', + hook: 'useQueryState', + testSSR: true, + nextJsRouter: 'app' +}) + +testShallow({ + path: '/app/shallow/useQueryStates', + hook: 'useQueryStates', + testSSR: true, + nextJsRouter: 'app' +}) + +testShallow({ + path: '/pages/shallow/useQueryState', + hook: 'useQueryState', + testSSR: true, + nextJsRouter: 'pages' +}) + +testShallow({ + path: '/pages/shallow/useQueryStates', + hook: 'useQueryStates', + testSSR: true, + nextJsRouter: 'pages' +}) diff --git a/packages/e2e/next/src/app/app/(shared)/shallow/useQueryState/page.tsx b/packages/e2e/next/src/app/app/(shared)/shallow/useQueryState/page.tsx new file mode 100644 index 000000000..7e215d647 --- /dev/null +++ b/packages/e2e/next/src/app/app/(shared)/shallow/useQueryState/page.tsx @@ -0,0 +1,35 @@ +import { ShallowUseQueryState } from 'e2e-shared/specs/shallow' +import { ShallowDisplay } from 'e2e-shared/specs/shallow.server' +import { + createSearchParamsCache, + parseAsString, + type SearchParams +} from 'nuqs/server' +import { Suspense } from 'react' + +type PageProps = { + searchParams: Promise +} + +const cache = createSearchParamsCache( + { + state: parseAsString + }, + { + urlKeys: { + state: 'test' + } + } +) + +export default async function Page({ searchParams }: PageProps) { + await cache.parse(searchParams) + return ( + <> + + + + + + ) +} diff --git a/packages/e2e/next/src/app/app/(shared)/shallow/useQueryStates/page.tsx b/packages/e2e/next/src/app/app/(shared)/shallow/useQueryStates/page.tsx new file mode 100644 index 000000000..c488668e6 --- /dev/null +++ b/packages/e2e/next/src/app/app/(shared)/shallow/useQueryStates/page.tsx @@ -0,0 +1,35 @@ +import { ShallowUseQueryStates } from 'e2e-shared/specs/shallow' +import { ShallowDisplay } from 'e2e-shared/specs/shallow.server' +import { + createSearchParamsCache, + parseAsString, + type SearchParams +} from 'nuqs/server' +import { Suspense } from 'react' + +type PageProps = { + searchParams: Promise +} + +const cache = createSearchParamsCache( + { + state: parseAsString + }, + { + urlKeys: { + state: 'test' + } + } +) + +export default async function Page({ searchParams }: PageProps) { + await cache.parse(searchParams) + return ( + <> + + + + + + ) +} diff --git a/packages/e2e/next/src/pages/pages/shallow/useQueryState.tsx b/packages/e2e/next/src/pages/pages/shallow/useQueryState.tsx new file mode 100644 index 000000000..462ab5bed --- /dev/null +++ b/packages/e2e/next/src/pages/pages/shallow/useQueryState.tsx @@ -0,0 +1,26 @@ +import { ShallowUseQueryState } from 'e2e-shared/specs/shallow' +import { ShallowDisplay } from 'e2e-shared/specs/shallow.server' +import type { GetServerSidePropsContext, GetServerSidePropsResult } from 'next' + +type Props = { + serverState: string | null +} + +export default function Page({ serverState }: Props) { + return ( + <> + + + + ) +} + +export function getServerSideProps( + ctx: GetServerSidePropsContext +): GetServerSidePropsResult { + return { + props: { + serverState: ctx.query.test as string | null + } + } +} diff --git a/packages/e2e/next/src/pages/pages/shallow/useQueryStates.tsx b/packages/e2e/next/src/pages/pages/shallow/useQueryStates.tsx new file mode 100644 index 000000000..9adbfce98 --- /dev/null +++ b/packages/e2e/next/src/pages/pages/shallow/useQueryStates.tsx @@ -0,0 +1,26 @@ +import { ShallowUseQueryStates } from 'e2e-shared/specs/shallow' +import { ShallowDisplay } from 'e2e-shared/specs/shallow.server' +import type { GetServerSidePropsContext, GetServerSidePropsResult } from 'next' + +type Props = { + serverState: string | null +} + +export default function Page({ serverState }: Props) { + return ( + <> + + + + ) +} + +export function getServerSideProps( + ctx: GetServerSidePropsContext +): GetServerSidePropsResult { + return { + props: { + serverState: ctx.query.test as string | null + } + } +} diff --git a/packages/e2e/shared/specs/shallow.cy.ts b/packages/e2e/shared/specs/shallow.cy.ts new file mode 100644 index 000000000..ffd55dce4 --- /dev/null +++ b/packages/e2e/shared/specs/shallow.cy.ts @@ -0,0 +1,51 @@ +import { createTest, type TestConfig } from '../create-test' +import { getShallowUrl } from './shallow.defs' + +type TestShallowOptions = TestConfig & { + testSSR: boolean + shallowOptions?: boolean[] + historyOptions?: ('replace' | 'push')[] +} + +export function testShallow({ + testSSR, + shallowOptions = [true, false], + historyOptions = ['replace', 'push'], + ...options +}: TestShallowOptions) { + const factory = createTest('Shallow', ({ path }) => { + for (const shallow of shallowOptions) { + for (const history of historyOptions) { + it(`Updates with ({ shallow: ${shallow}, history: ${history} })`, () => { + cy.visit(getShallowUrl(path, { shallow, history })) + cy.contains('#hydration-marker', 'hydrated').should('be.hidden') + cy.get('#client-state').should('be.empty') + if (testSSR) { + cy.get('#server-state').should('be.empty') + } + cy.get('button').click() + cy.get('#client-state').should('have.text', 'pass') + if (testSSR) { + if (shallow) { + cy.get('#server-state').should('be.empty') + } else { + cy.get('#server-state').should('have.text', 'pass') + } + } + if (history !== 'push') { + return + } + cy.go('back') + cy.get('#client-state').should('be.empty') + if (testSSR) { + cy.get('#server-state').should('be.empty') + } + }) + } + } + }) + if (testSSR) { + options.description = 'SSR' + } + return factory(options) +} diff --git a/packages/e2e/shared/specs/shallow.defs.ts b/packages/e2e/shared/specs/shallow.defs.ts new file mode 100644 index 000000000..925776ecb --- /dev/null +++ b/packages/e2e/shared/specs/shallow.defs.ts @@ -0,0 +1,8 @@ +import { createSerializer, parseAsBoolean, parseAsStringLiteral } from 'nuqs' + +export const shallowSearchParams = { + shallow: parseAsBoolean.withDefault(true), + history: parseAsStringLiteral(['replace', 'push']).withDefault('replace') +} + +export const getShallowUrl = createSerializer(shallowSearchParams) diff --git a/packages/e2e/shared/specs/shallow.server.tsx b/packages/e2e/shared/specs/shallow.server.tsx new file mode 100644 index 000000000..90a71c8d7 --- /dev/null +++ b/packages/e2e/shared/specs/shallow.server.tsx @@ -0,0 +1,8 @@ +type ShallowDisplayProps = { + environment: 'client' | 'server' + state: string | null +} + +export function ShallowDisplay({ state, environment }: ShallowDisplayProps) { + return
{state}
+} diff --git a/packages/e2e/shared/specs/shallow.tsx b/packages/e2e/shared/specs/shallow.tsx new file mode 100644 index 000000000..5dd8aac61 --- /dev/null +++ b/packages/e2e/shared/specs/shallow.tsx @@ -0,0 +1,36 @@ +'use client' + +import { parseAsString, useQueryState, useQueryStates } from 'nuqs' +import { shallowSearchParams } from './shallow.defs' +import { ShallowDisplay } from './shallow.server' + +export function ShallowUseQueryState() { + const [{ shallow, history }] = useQueryStates(shallowSearchParams) + const [state, setState] = useQueryState('test', { shallow, history }) + return ( + <> + + + + ) +} + +export function ShallowUseQueryStates() { + const [{ shallow, history }] = useQueryStates(shallowSearchParams) + const [{ state }, setSearchParams] = useQueryStates( + { + state: parseAsString.withOptions({ shallow, history }) + }, + { + urlKeys: { + state: 'test' + } + } + ) + return ( + <> + + + + ) +} From 1136dae306179b37c27ca6af6792dfdd360b791f Mon Sep 17 00:00:00 2001 From: Francois Best Date: Mon, 16 Dec 2024 22:31:34 +0100 Subject: [PATCH 06/23] feat: Add shallow support for Remix --- .../e2e/next/cypress/e2e/shared/shallow.cy.ts | 4 - .../(shared)/shallow/useQueryState/page.tsx | 2 +- .../(shared)/shallow/useQueryStates/page.tsx | 2 +- .../src/pages/pages/shallow/useQueryState.tsx | 2 +- .../pages/pages/shallow/useQueryStates.tsx | 2 +- .../app/routes/shallow.useQueryState.tsx | 21 +++++ .../app/routes/shallow.useQueryStates.tsx | 21 +++++ .../remix/cypress/e2e/shared/shallow.cy.ts | 11 +++ packages/e2e/shared/cypress.config.ts | 1 + ...shallow.server.tsx => shallow-display.tsx} | 0 packages/e2e/shared/specs/shallow.cy.ts | 11 +-- packages/e2e/shared/specs/shallow.tsx | 2 +- packages/nuqs/src/adapters/remix.ts | 92 ++++++++++++------- 13 files changed, 123 insertions(+), 48 deletions(-) create mode 100644 packages/e2e/remix/app/routes/shallow.useQueryState.tsx create mode 100644 packages/e2e/remix/app/routes/shallow.useQueryStates.tsx create mode 100644 packages/e2e/remix/cypress/e2e/shared/shallow.cy.ts rename packages/e2e/shared/specs/{shallow.server.tsx => shallow-display.tsx} (100%) diff --git a/packages/e2e/next/cypress/e2e/shared/shallow.cy.ts b/packages/e2e/next/cypress/e2e/shared/shallow.cy.ts index 02f30b626..382b04731 100644 --- a/packages/e2e/next/cypress/e2e/shared/shallow.cy.ts +++ b/packages/e2e/next/cypress/e2e/shared/shallow.cy.ts @@ -3,27 +3,23 @@ import { testShallow } from 'e2e-shared/specs/shallow.cy' testShallow({ path: '/app/shallow/useQueryState', hook: 'useQueryState', - testSSR: true, nextJsRouter: 'app' }) testShallow({ path: '/app/shallow/useQueryStates', hook: 'useQueryStates', - testSSR: true, nextJsRouter: 'app' }) testShallow({ path: '/pages/shallow/useQueryState', hook: 'useQueryState', - testSSR: true, nextJsRouter: 'pages' }) testShallow({ path: '/pages/shallow/useQueryStates', hook: 'useQueryStates', - testSSR: true, nextJsRouter: 'pages' }) diff --git a/packages/e2e/next/src/app/app/(shared)/shallow/useQueryState/page.tsx b/packages/e2e/next/src/app/app/(shared)/shallow/useQueryState/page.tsx index 7e215d647..3c5d4f82c 100644 --- a/packages/e2e/next/src/app/app/(shared)/shallow/useQueryState/page.tsx +++ b/packages/e2e/next/src/app/app/(shared)/shallow/useQueryState/page.tsx @@ -1,5 +1,5 @@ import { ShallowUseQueryState } from 'e2e-shared/specs/shallow' -import { ShallowDisplay } from 'e2e-shared/specs/shallow.server' +import { ShallowDisplay } from 'e2e-shared/specs/shallow-display' import { createSearchParamsCache, parseAsString, diff --git a/packages/e2e/next/src/app/app/(shared)/shallow/useQueryStates/page.tsx b/packages/e2e/next/src/app/app/(shared)/shallow/useQueryStates/page.tsx index c488668e6..221be41f4 100644 --- a/packages/e2e/next/src/app/app/(shared)/shallow/useQueryStates/page.tsx +++ b/packages/e2e/next/src/app/app/(shared)/shallow/useQueryStates/page.tsx @@ -1,5 +1,5 @@ import { ShallowUseQueryStates } from 'e2e-shared/specs/shallow' -import { ShallowDisplay } from 'e2e-shared/specs/shallow.server' +import { ShallowDisplay } from 'e2e-shared/specs/shallow-display' import { createSearchParamsCache, parseAsString, diff --git a/packages/e2e/next/src/pages/pages/shallow/useQueryState.tsx b/packages/e2e/next/src/pages/pages/shallow/useQueryState.tsx index 462ab5bed..6d440b4f4 100644 --- a/packages/e2e/next/src/pages/pages/shallow/useQueryState.tsx +++ b/packages/e2e/next/src/pages/pages/shallow/useQueryState.tsx @@ -1,5 +1,5 @@ import { ShallowUseQueryState } from 'e2e-shared/specs/shallow' -import { ShallowDisplay } from 'e2e-shared/specs/shallow.server' +import { ShallowDisplay } from 'e2e-shared/specs/shallow-display' import type { GetServerSidePropsContext, GetServerSidePropsResult } from 'next' type Props = { diff --git a/packages/e2e/next/src/pages/pages/shallow/useQueryStates.tsx b/packages/e2e/next/src/pages/pages/shallow/useQueryStates.tsx index 9adbfce98..83196ccac 100644 --- a/packages/e2e/next/src/pages/pages/shallow/useQueryStates.tsx +++ b/packages/e2e/next/src/pages/pages/shallow/useQueryStates.tsx @@ -1,5 +1,5 @@ import { ShallowUseQueryStates } from 'e2e-shared/specs/shallow' -import { ShallowDisplay } from 'e2e-shared/specs/shallow.server' +import { ShallowDisplay } from 'e2e-shared/specs/shallow-display' import type { GetServerSidePropsContext, GetServerSidePropsResult } from 'next' type Props = { diff --git a/packages/e2e/remix/app/routes/shallow.useQueryState.tsx b/packages/e2e/remix/app/routes/shallow.useQueryState.tsx new file mode 100644 index 000000000..adb32fff9 --- /dev/null +++ b/packages/e2e/remix/app/routes/shallow.useQueryState.tsx @@ -0,0 +1,21 @@ +import type { LoaderFunctionArgs } from '@remix-run/node' +import { useLoaderData } from '@remix-run/react' +import { ShallowUseQueryState } from 'e2e-shared/specs/shallow' +import { ShallowDisplay } from 'e2e-shared/specs/shallow-display' + +export async function loader({ request }: LoaderFunctionArgs) { + const url = new URL(request.url) + return { + serverState: url.searchParams.get('test') + } +} + +export default function Page() { + const { serverState } = useLoaderData() + return ( + <> + + + + ) +} diff --git a/packages/e2e/remix/app/routes/shallow.useQueryStates.tsx b/packages/e2e/remix/app/routes/shallow.useQueryStates.tsx new file mode 100644 index 000000000..3980cbad5 --- /dev/null +++ b/packages/e2e/remix/app/routes/shallow.useQueryStates.tsx @@ -0,0 +1,21 @@ +import type { LoaderFunctionArgs } from '@remix-run/node' +import { useLoaderData } from '@remix-run/react' +import { ShallowUseQueryStates } from 'e2e-shared/specs/shallow' +import { ShallowDisplay } from 'e2e-shared/specs/shallow-display' + +export async function loader({ request }: LoaderFunctionArgs) { + const url = new URL(request.url) + return { + serverState: url.searchParams.get('test') + } +} + +export default function Page() { + const { serverState } = useLoaderData() + return ( + <> + + + + ) +} diff --git a/packages/e2e/remix/cypress/e2e/shared/shallow.cy.ts b/packages/e2e/remix/cypress/e2e/shared/shallow.cy.ts new file mode 100644 index 000000000..423695536 --- /dev/null +++ b/packages/e2e/remix/cypress/e2e/shared/shallow.cy.ts @@ -0,0 +1,11 @@ +import { testShallow } from 'e2e-shared/specs/shallow.cy' + +testShallow({ + path: '/shallow/useQueryState', + hook: 'useQueryState' +}) + +testShallow({ + path: '/shallow/useQueryStates', + hook: 'useQueryStates' +}) diff --git a/packages/e2e/shared/cypress.config.ts b/packages/e2e/shared/cypress.config.ts index a63ba8a0c..dfc1dfe78 100644 --- a/packages/e2e/shared/cypress.config.ts +++ b/packages/e2e/shared/cypress.config.ts @@ -12,6 +12,7 @@ export function defineConfig(config: Config) { video: false, fixturesFolder: false, testIsolation: true, + defaultCommandTimeout: 500, setupNodeEvents(on) { cypressTerminalReport(on) }, diff --git a/packages/e2e/shared/specs/shallow.server.tsx b/packages/e2e/shared/specs/shallow-display.tsx similarity index 100% rename from packages/e2e/shared/specs/shallow.server.tsx rename to packages/e2e/shared/specs/shallow-display.tsx diff --git a/packages/e2e/shared/specs/shallow.cy.ts b/packages/e2e/shared/specs/shallow.cy.ts index ffd55dce4..7de333b18 100644 --- a/packages/e2e/shared/specs/shallow.cy.ts +++ b/packages/e2e/shared/specs/shallow.cy.ts @@ -2,13 +2,11 @@ import { createTest, type TestConfig } from '../create-test' import { getShallowUrl } from './shallow.defs' type TestShallowOptions = TestConfig & { - testSSR: boolean shallowOptions?: boolean[] historyOptions?: ('replace' | 'push')[] } export function testShallow({ - testSSR, shallowOptions = [true, false], historyOptions = ['replace', 'push'], ...options @@ -20,12 +18,12 @@ export function testShallow({ cy.visit(getShallowUrl(path, { shallow, history })) cy.contains('#hydration-marker', 'hydrated').should('be.hidden') cy.get('#client-state').should('be.empty') - if (testSSR) { + if (shallow === false) { cy.get('#server-state').should('be.empty') } cy.get('button').click() cy.get('#client-state').should('have.text', 'pass') - if (testSSR) { + if (shallow === false) { if (shallow) { cy.get('#server-state').should('be.empty') } else { @@ -37,15 +35,12 @@ export function testShallow({ } cy.go('back') cy.get('#client-state').should('be.empty') - if (testSSR) { + if (shallow === false) { cy.get('#server-state').should('be.empty') } }) } } }) - if (testSSR) { - options.description = 'SSR' - } return factory(options) } diff --git a/packages/e2e/shared/specs/shallow.tsx b/packages/e2e/shared/specs/shallow.tsx index 5dd8aac61..c7d6d96e2 100644 --- a/packages/e2e/shared/specs/shallow.tsx +++ b/packages/e2e/shared/specs/shallow.tsx @@ -1,8 +1,8 @@ 'use client' import { parseAsString, useQueryState, useQueryStates } from 'nuqs' +import { ShallowDisplay } from './shallow-display' import { shallowSearchParams } from './shallow.defs' -import { ShallowDisplay } from './shallow.server' export function ShallowUseQueryState() { const [{ shallow, history }] = useQueryStates(shallowSearchParams) diff --git a/packages/nuqs/src/adapters/remix.ts b/packages/nuqs/src/adapters/remix.ts index d0f01f65d..a8bca0541 100644 --- a/packages/nuqs/src/adapters/remix.ts +++ b/packages/nuqs/src/adapters/remix.ts @@ -3,9 +3,15 @@ import { useSearchParams as useRemixSearchParams } from '@remix-run/react' import mitt from 'mitt' -import { startTransition, useEffect, useLayoutEffect, useState } from 'react' +import { + startTransition, + useCallback, + useEffect, + useLayoutEffect, + useState +} from 'react' import { renderQueryString } from '../url-encoding' -import type { AdapterOptions } from './defs' +import type { AdapterInterface, AdapterOptions } from './defs' import { createAdapterProvider } from './internal.context' import { historyUpdateMarker, @@ -15,38 +21,58 @@ import { const emitter: SearchParamsSyncEmitter = mitt() -function useNuqsRemixAdapter() { +let searchParamsSnapshot = new URLSearchParams( + typeof location === 'object' ? location.search : '' +) + +function getSearchParamsSnapshot() { + return new URLSearchParams(location.search) +} + +function useNuqsRemixAdapter(): AdapterInterface { const navigate = useNavigate() const searchParams = useOptimisticSearchParams() - const updateUrl = (search: URLSearchParams, options: AdapterOptions) => { - startTransition(() => { - emitter.emit('update', search) - }) - const url = new URL(location.href) - url.search = renderQueryString(search) - // First, update the URL locally without triggering a network request, - // this allows keeping a reactive URL if the network is slow. - const updateMethod = - options.history === 'push' ? history.pushState : history.replaceState - updateMethod.call( - history, - history.state, // Maintain the history state - historyUpdateMarker, - url - ) - if (options.scroll) { - window.scrollTo(0, 0) - } - if (!options.shallow) { - navigate(url, { - replace: true, - preventScrollReset: true + const updateUrl = useCallback( + (search: URLSearchParams, options: AdapterOptions) => { + startTransition(() => { + emitter.emit('update', search) }) - } - } + const url = new URL(location.href) + url.search = renderQueryString(search) + // First, update the URL locally without triggering a network request, + // this allows keeping a reactive URL if the network is slow. + const updateMethod = + options.history === 'push' ? history.pushState : history.replaceState + updateMethod.call( + history, + history.state, // Maintain the history state + historyUpdateMarker, + url + ) + if (options.shallow === false) { + navigate( + { + // Somehow passing the full URL object here strips the search params + // when accessing the request.url in loaders. + hash: url.hash, + search: url.search + }, + { + replace: true, + preventScrollReset: true + } + ) + } + if (options.scroll) { + window.scrollTo(0, 0) + } + }, + [navigate] + ) return { searchParams, - updateUrl + updateUrl, + getSearchParamsSnapshot } } @@ -59,10 +85,14 @@ export function useOptimisticSearchParams() { function onPopState() { setSearchParams(new URLSearchParams(location.search)) } - emitter.on('update', setSearchParams) + function onEmitterUpdate(search: URLSearchParams) { + setSearchParams(search) + searchParamsSnapshot = search + } + emitter.on('update', onEmitterUpdate) window.addEventListener('popstate', onPopState) return () => { - emitter.off('update', setSearchParams) + emitter.off('update', onEmitterUpdate) window.removeEventListener('popstate', onPopState) } }, []) From 7597226a26dfdee05878edeca1689a49419f4756 Mon Sep 17 00:00:00 2001 From: Francois Best Date: Tue, 17 Dec 2024 06:00:50 +0100 Subject: [PATCH 07/23] chore: Address feedback --- packages/e2e/remix/app/routes/ssr.tsx | 56 ------------------- .../remix/cypress/e2e/shared/routing.cy.ts | 6 +- packages/e2e/shared/specs/shallow.cy.ts | 17 ++++-- 3 files changed, 13 insertions(+), 66 deletions(-) delete mode 100644 packages/e2e/remix/app/routes/ssr.tsx diff --git a/packages/e2e/remix/app/routes/ssr.tsx b/packages/e2e/remix/app/routes/ssr.tsx deleted file mode 100644 index 15e13f5f1..000000000 --- a/packages/e2e/remix/app/routes/ssr.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { useSearchParams } from '@remix-run/react' -import { parseAsString, useQueryState } from 'nuqs' -import { useOptimisticSearchParams } from 'nuqs/adapters/remix' - -export async function loader({ request }: { request: Request }) { - if (URL.canParse(request.url)) { - console.log(new URL(request.url).search) - } - return null -} - -export default function Component() { - const [shallow, setShallow] = useQueryState( - 'shallow', - parseAsString.withDefault('') - ) - const [deep, setDeep] = useQueryState( - 'deep', - parseAsString.withDefault('').withOptions({ - shallow: false, - throttleMs: 100 - }) - ) - const [searchParams] = useSearchParams() - const optimistic = useOptimisticSearchParams() - return ( - <> - setShallow(e.target.value)} - className="block" - /> - setDeep(e.target.value)} - className="block" - /> -
-        Remix useSearchParams:
-        
- {renderSearchParams(searchParams)} -
-
-        nuqs useOptimisticSearchParams:
-        
- {renderSearchParams(optimistic)} -
- - ) -} - -function renderSearchParams(searchParams: URLSearchParams) { - return JSON.stringify(Object.fromEntries(searchParams.entries()), null, 2) -} diff --git a/packages/e2e/remix/cypress/e2e/shared/routing.cy.ts b/packages/e2e/remix/cypress/e2e/shared/routing.cy.ts index fbe0d65aa..9c9e93bb3 100644 --- a/packages/e2e/remix/cypress/e2e/shared/routing.cy.ts +++ b/packages/e2e/remix/cypress/e2e/shared/routing.cy.ts @@ -2,12 +2,10 @@ import { testRouting } from 'e2e-shared/specs/routing.cy' testRouting({ path: '/routing/useQueryState', - hook: 'useQueryState', - shallowOptions: [true, false] + hook: 'useQueryState' }) testRouting({ path: '/routing/useQueryStates', - hook: 'useQueryStates', - shallowOptions: [true, false] + hook: 'useQueryStates' }) diff --git a/packages/e2e/shared/specs/shallow.cy.ts b/packages/e2e/shared/specs/shallow.cy.ts index 7de333b18..938140ba6 100644 --- a/packages/e2e/shared/specs/shallow.cy.ts +++ b/packages/e2e/shared/specs/shallow.cy.ts @@ -2,11 +2,13 @@ import { createTest, type TestConfig } from '../create-test' import { getShallowUrl } from './shallow.defs' type TestShallowOptions = TestConfig & { + supportsSSR?: boolean shallowOptions?: boolean[] historyOptions?: ('replace' | 'push')[] } export function testShallow({ + supportsSSR = true, shallowOptions = [true, false], historyOptions = ['replace', 'push'], ...options @@ -18,16 +20,16 @@ export function testShallow({ cy.visit(getShallowUrl(path, { shallow, history })) cy.contains('#hydration-marker', 'hydrated').should('be.hidden') cy.get('#client-state').should('be.empty') - if (shallow === false) { + if (supportsSSR) { cy.get('#server-state').should('be.empty') } cy.get('button').click() cy.get('#client-state').should('have.text', 'pass') - if (shallow === false) { - if (shallow) { - cy.get('#server-state').should('be.empty') - } else { + if (supportsSSR) { + if (shallow === false) { cy.get('#server-state').should('have.text', 'pass') + } else { + cy.get('#server-state').should('be.empty') } } if (history !== 'push') { @@ -35,12 +37,15 @@ export function testShallow({ } cy.go('back') cy.get('#client-state').should('be.empty') - if (shallow === false) { + if (supportsSSR) { cy.get('#server-state').should('be.empty') } }) } } }) + if (supportsSSR) { + options.description = 'SSR' + } return factory(options) } From 370a2c543ecd4dad52306dea485ae1aafd67bbef Mon Sep 17 00:00:00 2001 From: Francois Best Date: Tue, 17 Dec 2024 06:04:42 +0100 Subject: [PATCH 08/23] chore: Stream logs --- packages/e2e/remix/package.json | 2 +- packages/nuqs/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/e2e/remix/package.json b/packages/e2e/remix/package.json index 9f5dd9ac1..a4a1c746e 100644 --- a/packages/e2e/remix/package.json +++ b/packages/e2e/remix/package.json @@ -7,7 +7,7 @@ "build": "remix vite:build", "dev": "remix vite:dev --port 3003", "start": "cross-env NODE_ENV=production PORT=3003 remix-serve ./build/server/index.js", - "test": "pnpm run '/^test:/'", + "test": "pnpm run --stream '/^test:/'", "test:types": "tsc", "test:e2e": "start-server-and-test start http://localhost:3003 cypress:run", "cypress:open": "cypress open", diff --git a/packages/nuqs/package.json b/packages/nuqs/package.json index 2aa246d17..fc067b2a1 100644 --- a/packages/nuqs/package.json +++ b/packages/nuqs/package.json @@ -115,7 +115,7 @@ "prebuild": "rm -rf dist", "build": "tsup", "postbuild": "size-limit --json > size.json", - "test": "pnpm run '/^test:/'", + "test": "pnpm run --stream '/^test:/'", "test:types": "tsd", "test:unit": "vitest run", "test:size": "size-limit", From cdc42fae92544ff8bf38f5a967cab08ac611e6fc Mon Sep 17 00:00:00 2001 From: Francois Best Date: Tue, 17 Dec 2024 06:10:47 +0100 Subject: [PATCH 09/23] chore: Move adapters internal code --- packages/nuqs/src/adapters/custom.ts | 10 +++++----- .../adapters/{internal.context.ts => lib/context.ts} | 2 +- packages/nuqs/src/adapters/{ => lib}/defs.ts | 2 +- .../nuqs/src/adapters/{ => lib}/patch-history.test.ts | 0 packages/nuqs/src/adapters/{ => lib}/patch-history.ts | 4 ++-- packages/nuqs/src/adapters/next.ts | 4 ++-- packages/nuqs/src/adapters/next/app.ts | 2 +- packages/nuqs/src/adapters/next/impl.app.ts | 4 ++-- packages/nuqs/src/adapters/next/impl.pages.ts | 4 ++-- packages/nuqs/src/adapters/next/pages.ts | 2 +- packages/nuqs/src/adapters/react-router.ts | 4 ++-- packages/nuqs/src/adapters/react-router/v6.ts | 4 ++-- packages/nuqs/src/adapters/react-router/v7.ts | 4 ++-- packages/nuqs/src/adapters/react.ts | 4 ++-- packages/nuqs/src/adapters/remix.ts | 6 +++--- packages/nuqs/src/adapters/testing.ts | 4 ++-- packages/nuqs/src/update-queue.ts | 2 +- packages/nuqs/src/useQueryState.ts | 2 +- packages/nuqs/src/useQueryStates.ts | 2 +- 19 files changed, 33 insertions(+), 33 deletions(-) rename packages/nuqs/src/adapters/{internal.context.ts => lib/context.ts} (96%) rename packages/nuqs/src/adapters/{ => lib}/defs.ts (90%) rename packages/nuqs/src/adapters/{ => lib}/patch-history.test.ts (100%) rename packages/nuqs/src/adapters/{ => lib}/patch-history.ts (96%) diff --git a/packages/nuqs/src/adapters/custom.ts b/packages/nuqs/src/adapters/custom.ts index 80664974f..d7b50b82b 100644 --- a/packages/nuqs/src/adapters/custom.ts +++ b/packages/nuqs/src/adapters/custom.ts @@ -1,11 +1,11 @@ export { renderQueryString } from '../url-encoding' +export { + createAdapterProvider as unstable_createAdapterProvider, + type AdapterContext as unstable_AdapterContext +} from './lib/context' export type { AdapterInterface as unstable_AdapterInterface, AdapterOptions as unstable_AdapterOptions, UpdateUrlFunction as unstable_UpdateUrlFunction, UseAdapterHook as unstable_UseAdapterHook -} from './defs' -export { - createAdapterProvider as unstable_createAdapterProvider, - type AdapterContext as unstable_AdapterContext -} from './internal.context' +} from './lib/defs' diff --git a/packages/nuqs/src/adapters/internal.context.ts b/packages/nuqs/src/adapters/lib/context.ts similarity index 96% rename from packages/nuqs/src/adapters/internal.context.ts rename to packages/nuqs/src/adapters/lib/context.ts index 9b646af29..034fa773a 100644 --- a/packages/nuqs/src/adapters/internal.context.ts +++ b/packages/nuqs/src/adapters/lib/context.ts @@ -1,5 +1,5 @@ import { createContext, createElement, useContext, type ReactNode } from 'react' -import { error } from '../errors' +import { error } from '../../errors' import type { UseAdapterHook } from './defs' export type AdapterContext = { diff --git a/packages/nuqs/src/adapters/defs.ts b/packages/nuqs/src/adapters/lib/defs.ts similarity index 90% rename from packages/nuqs/src/adapters/defs.ts rename to packages/nuqs/src/adapters/lib/defs.ts index a1a0bfaf8..c1d3c1e44 100644 --- a/packages/nuqs/src/adapters/defs.ts +++ b/packages/nuqs/src/adapters/lib/defs.ts @@ -1,4 +1,4 @@ -import type { Options } from '../defs' +import type { Options } from '../../defs' export type AdapterOptions = Pick diff --git a/packages/nuqs/src/adapters/patch-history.test.ts b/packages/nuqs/src/adapters/lib/patch-history.test.ts similarity index 100% rename from packages/nuqs/src/adapters/patch-history.test.ts rename to packages/nuqs/src/adapters/lib/patch-history.test.ts diff --git a/packages/nuqs/src/adapters/patch-history.ts b/packages/nuqs/src/adapters/lib/patch-history.ts similarity index 96% rename from packages/nuqs/src/adapters/patch-history.ts rename to packages/nuqs/src/adapters/lib/patch-history.ts index c43328baf..d3404a1a3 100644 --- a/packages/nuqs/src/adapters/patch-history.ts +++ b/packages/nuqs/src/adapters/lib/patch-history.ts @@ -1,6 +1,6 @@ import type { Emitter } from 'mitt' -import { debug } from '../debug' -import { error } from '../errors' +import { debug } from '../../debug' +import { error } from '../../errors' export type SearchParamsSyncEmitter = Emitter<{ update: URLSearchParams }> diff --git a/packages/nuqs/src/adapters/next.ts b/packages/nuqs/src/adapters/next.ts index 62bfa2c6f..c59dd8264 100644 --- a/packages/nuqs/src/adapters/next.ts +++ b/packages/nuqs/src/adapters/next.ts @@ -1,5 +1,5 @@ -import type { AdapterInterface } from './defs' -import { createAdapterProvider } from './internal.context' +import { createAdapterProvider } from './lib/context' +import type { AdapterInterface } from './lib/defs' import { useNuqsNextAppRouterAdapter } from './next/impl.app' import { isPagesRouter, useNuqsNextPagesRouterAdapter } from './next/impl.pages' diff --git a/packages/nuqs/src/adapters/next/app.ts b/packages/nuqs/src/adapters/next/app.ts index 68910ca42..7ca874f86 100644 --- a/packages/nuqs/src/adapters/next/app.ts +++ b/packages/nuqs/src/adapters/next/app.ts @@ -1,4 +1,4 @@ -import { createAdapterProvider } from '../internal.context' +import { createAdapterProvider } from '../lib/context' import { useNuqsNextAppRouterAdapter } from './impl.app' export const NuqsAdapter = createAdapterProvider(useNuqsNextAppRouterAdapter) diff --git a/packages/nuqs/src/adapters/next/impl.app.ts b/packages/nuqs/src/adapters/next/impl.app.ts index eee281e3c..43214c49a 100644 --- a/packages/nuqs/src/adapters/next/impl.app.ts +++ b/packages/nuqs/src/adapters/next/impl.app.ts @@ -1,7 +1,7 @@ import { useRouter, useSearchParams } from 'next/navigation' -import { useCallback, useOptimistic, startTransition } from 'react' +import { startTransition, useCallback, useOptimistic } from 'react' import { debug } from '../../debug' -import type { AdapterInterface, UpdateUrlFunction } from '../defs' +import type { AdapterInterface, UpdateUrlFunction } from '../lib/defs' import { renderURL } from './shared' export function useNuqsNextAppRouterAdapter(): AdapterInterface { diff --git a/packages/nuqs/src/adapters/next/impl.pages.ts b/packages/nuqs/src/adapters/next/impl.pages.ts index 8ea335d22..6b98eaa96 100644 --- a/packages/nuqs/src/adapters/next/impl.pages.ts +++ b/packages/nuqs/src/adapters/next/impl.pages.ts @@ -2,8 +2,8 @@ import { useSearchParams } from 'next/navigation.js' import type { NextRouter } from 'next/router' import { useCallback } from 'react' import { debug } from '../../debug' -import type { AdapterInterface, UpdateUrlFunction } from '../defs' -import { createAdapterProvider } from '../internal.context' +import { createAdapterProvider } from '../lib/context' +import type { AdapterInterface, UpdateUrlFunction } from '../lib/defs' import { renderURL } from './shared' declare global { diff --git a/packages/nuqs/src/adapters/next/pages.ts b/packages/nuqs/src/adapters/next/pages.ts index 4e1e17634..c6fbedd5c 100644 --- a/packages/nuqs/src/adapters/next/pages.ts +++ b/packages/nuqs/src/adapters/next/pages.ts @@ -1,4 +1,4 @@ -import { createAdapterProvider } from '../internal.context' +import { createAdapterProvider } from '../lib/context' import { useNuqsNextPagesRouterAdapter } from './impl.pages' export const NuqsAdapter = createAdapterProvider(useNuqsNextPagesRouterAdapter) diff --git a/packages/nuqs/src/adapters/react-router.ts b/packages/nuqs/src/adapters/react-router.ts index a2c9041d8..b5803910b 100644 --- a/packages/nuqs/src/adapters/react-router.ts +++ b/packages/nuqs/src/adapters/react-router.ts @@ -3,8 +3,8 @@ import { useNavigate, useSearchParams } from 'react-router-dom' import { renderQueryString } from '../url-encoding' -import type { AdapterOptions } from './defs' -import { createAdapterProvider } from './internal.context' +import { createAdapterProvider } from './lib/context' +import type { AdapterOptions } from './lib/defs' function useNuqsReactRouterV6Adapter() { const navigate = useNavigate() diff --git a/packages/nuqs/src/adapters/react-router/v6.ts b/packages/nuqs/src/adapters/react-router/v6.ts index 2102008b2..7a746e7f8 100644 --- a/packages/nuqs/src/adapters/react-router/v6.ts +++ b/packages/nuqs/src/adapters/react-router/v6.ts @@ -1,7 +1,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom' import { renderQueryString } from '../../url-encoding' -import type { AdapterOptions } from '../defs' -import { createAdapterProvider } from '../internal.context' +import { createAdapterProvider } from '../lib/context' +import type { AdapterOptions } from '../lib/defs' function useNuqsReactRouterV6Adapter() { const navigate = useNavigate() diff --git a/packages/nuqs/src/adapters/react-router/v7.ts b/packages/nuqs/src/adapters/react-router/v7.ts index 934b7db8e..4605232a5 100644 --- a/packages/nuqs/src/adapters/react-router/v7.ts +++ b/packages/nuqs/src/adapters/react-router/v7.ts @@ -1,7 +1,7 @@ import { useNavigate, useSearchParams } from 'react-router' import { renderQueryString } from '../../url-encoding' -import type { AdapterOptions } from '../defs' -import { createAdapterProvider } from '../internal.context' +import { createAdapterProvider } from '../lib/context' +import type { AdapterOptions } from '../lib/defs' function useNuqsReactRouterV7Adapter() { const navigate = useNavigate() diff --git a/packages/nuqs/src/adapters/react.ts b/packages/nuqs/src/adapters/react.ts index b35818eba..66b4889c2 100644 --- a/packages/nuqs/src/adapters/react.ts +++ b/packages/nuqs/src/adapters/react.ts @@ -1,8 +1,8 @@ import mitt from 'mitt' import { useEffect, useState } from 'react' import { renderQueryString } from '../url-encoding' -import type { AdapterOptions } from './defs' -import { createAdapterProvider } from './internal.context' +import { createAdapterProvider } from './lib/context' +import type { AdapterOptions } from './lib/defs' const emitter = mitt<{ update: URLSearchParams }>() diff --git a/packages/nuqs/src/adapters/remix.ts b/packages/nuqs/src/adapters/remix.ts index a8bca0541..e7aa57d4e 100644 --- a/packages/nuqs/src/adapters/remix.ts +++ b/packages/nuqs/src/adapters/remix.ts @@ -11,13 +11,13 @@ import { useState } from 'react' import { renderQueryString } from '../url-encoding' -import type { AdapterInterface, AdapterOptions } from './defs' -import { createAdapterProvider } from './internal.context' +import { createAdapterProvider } from './lib/context' +import type { AdapterInterface, AdapterOptions } from './lib/defs' import { historyUpdateMarker, patchHistory, type SearchParamsSyncEmitter -} from './patch-history' +} from './lib/patch-history' const emitter: SearchParamsSyncEmitter = mitt() diff --git a/packages/nuqs/src/adapters/testing.ts b/packages/nuqs/src/adapters/testing.ts index ed8d9f21b..7dfc10306 100644 --- a/packages/nuqs/src/adapters/testing.ts +++ b/packages/nuqs/src/adapters/testing.ts @@ -1,8 +1,8 @@ import { createElement, type ReactNode } from 'react' import { resetQueue } from '../update-queue' import { renderQueryString } from '../url-encoding' -import type { AdapterInterface, AdapterOptions } from './defs' -import { context } from './internal.context' +import { context } from './lib/context' +import type { AdapterInterface, AdapterOptions } from './lib/defs' export type UrlUpdateEvent = { searchParams: URLSearchParams diff --git a/packages/nuqs/src/update-queue.ts b/packages/nuqs/src/update-queue.ts index 83684a6a8..e396f99d0 100644 --- a/packages/nuqs/src/update-queue.ts +++ b/packages/nuqs/src/update-queue.ts @@ -1,4 +1,4 @@ -import type { AdapterInterface } from './adapters/defs' +import type { AdapterInterface } from './adapters/lib/defs' import { debug } from './debug' import type { Options } from './defs' import { error } from './errors' diff --git a/packages/nuqs/src/useQueryState.ts b/packages/nuqs/src/useQueryState.ts index 338201b6d..faa9da550 100644 --- a/packages/nuqs/src/useQueryState.ts +++ b/packages/nuqs/src/useQueryState.ts @@ -5,7 +5,7 @@ import { useRef, useState } from 'react' -import { useAdapter } from './adapters/internal.context' +import { useAdapter } from './adapters/lib/context' import { debug } from './debug' import type { Options } from './defs' import type { Parser } from './parsers' diff --git a/packages/nuqs/src/useQueryStates.ts b/packages/nuqs/src/useQueryStates.ts index 7b7775adb..0fb7d7441 100644 --- a/packages/nuqs/src/useQueryStates.ts +++ b/packages/nuqs/src/useQueryStates.ts @@ -6,7 +6,7 @@ import { useRef, useState } from 'react' -import { useAdapter } from './adapters/internal.context' +import { useAdapter } from './adapters/lib/context' import { debug } from './debug' import type { Nullable, Options } from './defs' import type { Parser } from './parsers' From 94fc9b64e54dc60d4a9101c9f455089b1dd08127 Mon Sep 17 00:00:00 2001 From: Francois Best Date: Tue, 17 Dec 2024 06:16:43 +0100 Subject: [PATCH 10/23] test: Add shallow support for RRv7 --- packages/e2e/react-router/v7/app/routes.ts | 4 +- .../v7/app/routes/shallow.useQueryState.tsx | 20 +++ .../v7/app/routes/shallow.useQueryStates.tsx | 20 +++ .../v7/cypress/e2e/shared/shallow.cy.ts | 11 ++ packages/nuqs/src/adapters/react-router.ts | 38 ++---- packages/nuqs/src/adapters/react-router/v7.ts | 121 +++++++++++++++--- 6 files changed, 167 insertions(+), 47 deletions(-) create mode 100644 packages/e2e/react-router/v7/app/routes/shallow.useQueryState.tsx create mode 100644 packages/e2e/react-router/v7/app/routes/shallow.useQueryStates.tsx create mode 100644 packages/e2e/react-router/v7/cypress/e2e/shared/shallow.cy.ts diff --git a/packages/e2e/react-router/v7/app/routes.ts b/packages/e2e/react-router/v7/app/routes.ts index 931b744a2..df91927ab 100644 --- a/packages/e2e/react-router/v7/app/routes.ts +++ b/packages/e2e/react-router/v7/app/routes.ts @@ -15,6 +15,8 @@ export default [ route('/routing/useQueryState', './routes/routing.useQueryState.tsx'), route('/routing/useQueryState/other', './routes/routing.useQueryState.other.tsx'), route('/routing/useQueryStates', './routes/routing.useQueryStates.tsx'), - route('/routing/useQueryStates/other', './routes/routing.useQueryStates.other.tsx') + route('/routing/useQueryStates/other', './routes/routing.useQueryStates.other.tsx'), + route('/shallow/useQueryState', './routes/shallow.useQueryState.tsx'), + route('/shallow/useQueryStates', './routes/shallow.useQueryStates.tsx'), ]) ] satisfies RouteConfig diff --git a/packages/e2e/react-router/v7/app/routes/shallow.useQueryState.tsx b/packages/e2e/react-router/v7/app/routes/shallow.useQueryState.tsx new file mode 100644 index 000000000..d30cdaa44 --- /dev/null +++ b/packages/e2e/react-router/v7/app/routes/shallow.useQueryState.tsx @@ -0,0 +1,20 @@ +import { ShallowUseQueryState } from 'e2e-shared/specs/shallow' +import { ShallowDisplay } from 'e2e-shared/specs/shallow-display' +import { useLoaderData, type LoaderFunctionArgs } from 'react-router' + +export async function loader({ request }: LoaderFunctionArgs) { + const url = new URL(request.url) + return { + serverState: url.searchParams.get('test') + } +} + +export default function Page() { + const { serverState } = useLoaderData() + return ( + <> + + + + ) +} diff --git a/packages/e2e/react-router/v7/app/routes/shallow.useQueryStates.tsx b/packages/e2e/react-router/v7/app/routes/shallow.useQueryStates.tsx new file mode 100644 index 000000000..aa8210839 --- /dev/null +++ b/packages/e2e/react-router/v7/app/routes/shallow.useQueryStates.tsx @@ -0,0 +1,20 @@ +import { ShallowUseQueryStates } from 'e2e-shared/specs/shallow' +import { ShallowDisplay } from 'e2e-shared/specs/shallow-display' +import { useLoaderData, type LoaderFunctionArgs } from 'react-router' + +export async function loader({ request }: LoaderFunctionArgs) { + const url = new URL(request.url) + return { + serverState: url.searchParams.get('test') + } +} + +export default function Page() { + const { serverState } = useLoaderData() + return ( + <> + + + + ) +} diff --git a/packages/e2e/react-router/v7/cypress/e2e/shared/shallow.cy.ts b/packages/e2e/react-router/v7/cypress/e2e/shared/shallow.cy.ts new file mode 100644 index 000000000..423695536 --- /dev/null +++ b/packages/e2e/react-router/v7/cypress/e2e/shared/shallow.cy.ts @@ -0,0 +1,11 @@ +import { testShallow } from 'e2e-shared/specs/shallow.cy' + +testShallow({ + path: '/shallow/useQueryState', + hook: 'useQueryState' +}) + +testShallow({ + path: '/shallow/useQueryStates', + hook: 'useQueryStates' +}) diff --git a/packages/nuqs/src/adapters/react-router.ts b/packages/nuqs/src/adapters/react-router.ts index b5803910b..f56d91c5e 100644 --- a/packages/nuqs/src/adapters/react-router.ts +++ b/packages/nuqs/src/adapters/react-router.ts @@ -1,29 +1,9 @@ -// Note: this default react-router adapter is for react-router v6. -// If you are using react-router v7, please import from `nuqs/adapters/react-router/v7` - -import { useNavigate, useSearchParams } from 'react-router-dom' -import { renderQueryString } from '../url-encoding' -import { createAdapterProvider } from './lib/context' -import type { AdapterOptions } from './lib/defs' - -function useNuqsReactRouterV6Adapter() { - const navigate = useNavigate() - const [searchParams] = useSearchParams() - const updateUrl = (search: URLSearchParams, options: AdapterOptions) => { - navigate( - { - search: renderQueryString(search) - }, - { - replace: options.history === 'replace', - preventScrollReset: !options.scroll - } - ) - } - return { - searchParams, - updateUrl - } -} - -export const NuqsAdapter = createAdapterProvider(useNuqsReactRouterV6Adapter) +/** + * Note: this default react-router adapter is for react-router v6. + * If you are using react-router v7, please import from `nuqs/adapters/react-router/v7` + * + * This shorthand import will be removed in nuqs@3.0.0. + * + * @deprecated Please pin your version of react-router in the import: `nuqs/adapters/react-router/v6` or `nuqs/adapters/react-router/v7`. + */ +export { NuqsAdapter } from './react-router/v6' diff --git a/packages/nuqs/src/adapters/react-router/v7.ts b/packages/nuqs/src/adapters/react-router/v7.ts index 4605232a5..47a372e2e 100644 --- a/packages/nuqs/src/adapters/react-router/v7.ts +++ b/packages/nuqs/src/adapters/react-router/v7.ts @@ -1,27 +1,114 @@ -import { useNavigate, useSearchParams } from 'react-router' +import mitt from 'mitt' +import { + startTransition, + useCallback, + useEffect, + useLayoutEffect, + useState +} from 'react' +import { + useNavigate, + useSearchParams as useReactRouterSearchParams +} from 'react-router' import { renderQueryString } from '../../url-encoding' import { createAdapterProvider } from '../lib/context' -import type { AdapterOptions } from '../lib/defs' +import type { AdapterInterface, AdapterOptions } from '../lib/defs' +import { + historyUpdateMarker, + patchHistory, + type SearchParamsSyncEmitter +} from '../lib/patch-history' -function useNuqsReactRouterV7Adapter() { +const emitter: SearchParamsSyncEmitter = mitt() + +let searchParamsSnapshot = new URLSearchParams( + typeof location === 'object' ? location.search : '' +) + +function getSearchParamsSnapshot() { + return new URLSearchParams(location.search) +} + +function useNuqsRemixAdapter(): AdapterInterface { const navigate = useNavigate() - const [searchParams] = useSearchParams() - const updateUrl = (search: URLSearchParams, options: AdapterOptions) => { - navigate( - { - search: renderQueryString(search), - hash: location.hash - }, - { - replace: options.history === 'replace', - preventScrollReset: !options.scroll + const searchParams = useOptimisticSearchParams() + const updateUrl = useCallback( + (search: URLSearchParams, options: AdapterOptions) => { + startTransition(() => { + emitter.emit('update', search) + }) + const url = new URL(location.href) + url.search = renderQueryString(search) + // First, update the URL locally without triggering a network request, + // this allows keeping a reactive URL if the network is slow. + const updateMethod = + options.history === 'push' ? history.pushState : history.replaceState + updateMethod.call( + history, + history.state, // Maintain the history state + historyUpdateMarker, + url + ) + if (options.shallow === false) { + navigate( + { + // Somehow passing the full URL object here strips the search params + // when accessing the request.url in loaders. + hash: url.hash, + search: url.search + }, + { + replace: true, + preventScrollReset: true + } + ) } - ) - } + if (options.scroll) { + window.scrollTo(0, 0) + } + }, + [navigate] + ) return { searchParams, - updateUrl + updateUrl, + getSearchParamsSnapshot } } -export const NuqsAdapter = createAdapterProvider(useNuqsReactRouterV7Adapter) +export const NuqsAdapter = createAdapterProvider(useNuqsRemixAdapter) + +export function useOptimisticSearchParams() { + const [serverSearchParams] = useReactRouterSearchParams() + const [searchParams, setSearchParams] = useState(serverSearchParams) + useEffect(() => { + function onPopState() { + setSearchParams(new URLSearchParams(location.search)) + } + function onEmitterUpdate(search: URLSearchParams) { + setSearchParams(search) + searchParamsSnapshot = search + } + emitter.on('update', onEmitterUpdate) + window.addEventListener('popstate', onPopState) + return () => { + emitter.off('update', onEmitterUpdate) + window.removeEventListener('popstate', onPopState) + } + }, []) + useLayoutEffect(() => { + emitter.emit('update', serverSearchParams) + }, [serverSearchParams]) + return searchParams +} + +/** + * Opt-in to syncing shallow updates of the URL with the useOptimisticSearchParams hook. + * + * By default, the useOptimisticSearchParams hook will only react to internal nuqs updates. + * If third party code updates the History API directly, use this function to + * enable useOptimisticSearchParams to react to those changes. + */ +export function enableHistorySync() { + patchHistory(emitter, 'react-router-v7') +} From 0f22bf1183894265fefecd5dcba15ac7319eb8be Mon Sep 17 00:00:00 2001 From: Francois Best Date: Tue, 17 Dec 2024 06:27:10 +0100 Subject: [PATCH 11/23] test: Add shallow routing for RRv6 --- .../v6/cypress/e2e/shared/shallow.cy.ts | 11 ++ .../e2e/react-router/v6/src/react-router.tsx | 10 +- .../v6/src/routes/shallow.useQueryState.tsx | 20 +++ .../v6/src/routes/shallow.useQueryStates.tsx | 20 +++ packages/nuqs/src/adapters/react-router/v6.ts | 119 +++++++++++++++--- packages/nuqs/src/adapters/react-router/v7.ts | 4 +- 6 files changed, 164 insertions(+), 20 deletions(-) create mode 100644 packages/e2e/react-router/v6/cypress/e2e/shared/shallow.cy.ts create mode 100644 packages/e2e/react-router/v6/src/routes/shallow.useQueryState.tsx create mode 100644 packages/e2e/react-router/v6/src/routes/shallow.useQueryStates.tsx diff --git a/packages/e2e/react-router/v6/cypress/e2e/shared/shallow.cy.ts b/packages/e2e/react-router/v6/cypress/e2e/shared/shallow.cy.ts new file mode 100644 index 000000000..423695536 --- /dev/null +++ b/packages/e2e/react-router/v6/cypress/e2e/shared/shallow.cy.ts @@ -0,0 +1,11 @@ +import { testShallow } from 'e2e-shared/specs/shallow.cy' + +testShallow({ + path: '/shallow/useQueryState', + hook: 'useQueryState' +}) + +testShallow({ + path: '/shallow/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 6e7fdb774..7efcf48fe 100644 --- a/packages/e2e/react-router/v6/src/react-router.tsx +++ b/packages/e2e/react-router/v6/src/react-router.tsx @@ -8,8 +8,12 @@ import { import RootLayout from './layout' // Adapt the RRv7 / Remix default export for component into a Component export for v6 -function load(mod: Promise<{ default: any }>) { - return () => mod.then(m => ({ Component: m.default })) +function load(mod: Promise<{ default: any; [otherExports: string]: any }>) { + return () => + mod.then(({ default: Component, ...otherExports }) => ({ + Component, + ...otherExports + })) } // prettier-ignore @@ -29,6 +33,8 @@ const router = createBrowserRouter( + + )) diff --git a/packages/e2e/react-router/v6/src/routes/shallow.useQueryState.tsx b/packages/e2e/react-router/v6/src/routes/shallow.useQueryState.tsx new file mode 100644 index 000000000..c63329d7a --- /dev/null +++ b/packages/e2e/react-router/v6/src/routes/shallow.useQueryState.tsx @@ -0,0 +1,20 @@ +import { ShallowUseQueryState } from 'e2e-shared/specs/shallow' +import { ShallowDisplay } from 'e2e-shared/specs/shallow-display' +import { useLoaderData, type LoaderFunctionArgs } from 'react-router-dom' + +export async function loader({ request }: LoaderFunctionArgs) { + const url = new URL(request.url) + return { + serverState: url.searchParams.get('test') + } +} + +export default function Page() { + const { serverState } = useLoaderData() as Awaited> + return ( + <> + + + + ) +} diff --git a/packages/e2e/react-router/v6/src/routes/shallow.useQueryStates.tsx b/packages/e2e/react-router/v6/src/routes/shallow.useQueryStates.tsx new file mode 100644 index 000000000..fb3df68c4 --- /dev/null +++ b/packages/e2e/react-router/v6/src/routes/shallow.useQueryStates.tsx @@ -0,0 +1,20 @@ +import { ShallowUseQueryStates } from 'e2e-shared/specs/shallow' +import { ShallowDisplay } from 'e2e-shared/specs/shallow-display' +import { useLoaderData, type LoaderFunctionArgs } from 'react-router-dom' + +export async function loader({ request }: LoaderFunctionArgs) { + const url = new URL(request.url) + return { + serverState: url.searchParams.get('test') + } +} + +export default function Page() { + const { serverState } = useLoaderData() as Awaited> + return ( + <> + + + + ) +} diff --git a/packages/nuqs/src/adapters/react-router/v6.ts b/packages/nuqs/src/adapters/react-router/v6.ts index 7a746e7f8..07e4309f4 100644 --- a/packages/nuqs/src/adapters/react-router/v6.ts +++ b/packages/nuqs/src/adapters/react-router/v6.ts @@ -1,27 +1,114 @@ -import { useNavigate, useSearchParams } from 'react-router-dom' +import mitt from 'mitt' +import { + startTransition, + useCallback, + useEffect, + useLayoutEffect, + useState +} from 'react' +import { + useNavigate, + useSearchParams as useReactRouterSearchParams +} from 'react-router-dom' import { renderQueryString } from '../../url-encoding' import { createAdapterProvider } from '../lib/context' -import type { AdapterOptions } from '../lib/defs' +import type { AdapterInterface, AdapterOptions } from '../lib/defs' +import { + historyUpdateMarker, + patchHistory, + type SearchParamsSyncEmitter +} from '../lib/patch-history' -function useNuqsReactRouterV6Adapter() { +const emitter: SearchParamsSyncEmitter = mitt() + +let searchParamsSnapshot = new URLSearchParams( + typeof location === 'object' ? location.search : '' +) + +function getSearchParamsSnapshot() { + return new URLSearchParams(location.search) +} + +function useNuqsReactRouterV6Adapter(): AdapterInterface { const navigate = useNavigate() - const [searchParams] = useSearchParams() - const updateUrl = (search: URLSearchParams, options: AdapterOptions) => { - navigate( - { - search: renderQueryString(search), - hash: location.hash - }, - { - replace: options.history === 'replace', - preventScrollReset: !options.scroll + const searchParams = useOptimisticSearchParams() + const updateUrl = useCallback( + (search: URLSearchParams, options: AdapterOptions) => { + startTransition(() => { + emitter.emit('update', search) + }) + const url = new URL(location.href) + url.search = renderQueryString(search) + // First, update the URL locally without triggering a network request, + // this allows keeping a reactive URL if the network is slow. + const updateMethod = + options.history === 'push' ? history.pushState : history.replaceState + updateMethod.call( + history, + history.state, // Maintain the history state + historyUpdateMarker, + url + ) + if (options.shallow === false) { + navigate( + { + // Somehow passing the full URL object here strips the search params + // when accessing the request.url in loaders. + hash: url.hash, + search: url.search + }, + { + replace: true, + preventScrollReset: true + } + ) } - ) - } + if (options.scroll) { + window.scrollTo(0, 0) + } + }, + [navigate] + ) return { searchParams, - updateUrl + updateUrl, + getSearchParamsSnapshot } } export const NuqsAdapter = createAdapterProvider(useNuqsReactRouterV6Adapter) + +export function useOptimisticSearchParams() { + const [serverSearchParams] = useReactRouterSearchParams() + const [searchParams, setSearchParams] = useState(serverSearchParams) + useEffect(() => { + function onPopState() { + setSearchParams(new URLSearchParams(location.search)) + } + function onEmitterUpdate(search: URLSearchParams) { + setSearchParams(search) + searchParamsSnapshot = search + } + emitter.on('update', onEmitterUpdate) + window.addEventListener('popstate', onPopState) + return () => { + emitter.off('update', onEmitterUpdate) + window.removeEventListener('popstate', onPopState) + } + }, []) + useLayoutEffect(() => { + emitter.emit('update', serverSearchParams) + }, [serverSearchParams]) + return searchParams +} + +/** + * Opt-in to syncing shallow updates of the URL with the useOptimisticSearchParams hook. + * + * By default, the useOptimisticSearchParams hook will only react to internal nuqs updates. + * If third party code updates the History API directly, use this function to + * enable useOptimisticSearchParams to react to those changes. + */ +export function enableHistorySync() { + patchHistory(emitter, 'react-router-v6') +} diff --git a/packages/nuqs/src/adapters/react-router/v7.ts b/packages/nuqs/src/adapters/react-router/v7.ts index 47a372e2e..5423e01b0 100644 --- a/packages/nuqs/src/adapters/react-router/v7.ts +++ b/packages/nuqs/src/adapters/react-router/v7.ts @@ -29,7 +29,7 @@ function getSearchParamsSnapshot() { return new URLSearchParams(location.search) } -function useNuqsRemixAdapter(): AdapterInterface { +function useNuqsReactRouterV7Adapter(): AdapterInterface { const navigate = useNavigate() const searchParams = useOptimisticSearchParams() const updateUrl = useCallback( @@ -76,7 +76,7 @@ function useNuqsRemixAdapter(): AdapterInterface { } } -export const NuqsAdapter = createAdapterProvider(useNuqsRemixAdapter) +export const NuqsAdapter = createAdapterProvider(useNuqsReactRouterV7Adapter) export function useOptimisticSearchParams() { const [serverSearchParams] = useReactRouterSearchParams() From 5a77786235f707837a4e3d97f1119c5631f37ae5 Mon Sep 17 00:00:00 2001 From: Francois Best Date: Tue, 17 Dec 2024 09:50:49 +0100 Subject: [PATCH 12/23] chore: Disable node:test autoimport suggestions --- .vscode/settings.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.vscode/settings.json b/.vscode/settings.json index 1f95a140a..cb2c772f0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -25,5 +25,8 @@ "label": "Internal", "query": "repo:47ng/nuqs is:issue label:internal" } + ], + "typescript.preferences.autoImportSpecifierExcludeRegexes": [ + "^node:test$" // We use Vitest ] } From 8a2f3cd4cb6706e8a87805ba5b83724fe555650b Mon Sep 17 00:00:00 2001 From: Francois Best Date: Tue, 17 Dec 2024 10:17:41 +0100 Subject: [PATCH 13/23] test: Add shallow test for React (no-op on shallow: false) --- packages/e2e/react/cypress/e2e/shared/shallow.cy.ts | 13 +++++++++++++ packages/e2e/react/src/routes.tsx | 4 +++- .../e2e/react/src/routes/shallow.useQueryState.tsx | 3 +++ .../e2e/react/src/routes/shallow.useQueryStates.tsx | 3 +++ 4 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 packages/e2e/react/cypress/e2e/shared/shallow.cy.ts create mode 100644 packages/e2e/react/src/routes/shallow.useQueryState.tsx create mode 100644 packages/e2e/react/src/routes/shallow.useQueryStates.tsx diff --git a/packages/e2e/react/cypress/e2e/shared/shallow.cy.ts b/packages/e2e/react/cypress/e2e/shared/shallow.cy.ts new file mode 100644 index 000000000..258b586b9 --- /dev/null +++ b/packages/e2e/react/cypress/e2e/shared/shallow.cy.ts @@ -0,0 +1,13 @@ +import { testShallow } from 'e2e-shared/specs/shallow.cy' + +testShallow({ + path: '/shallow/useQueryState', + hook: 'useQueryState', + supportsSSR: false +}) + +testShallow({ + path: '/shallow/useQueryStates', + hook: 'useQueryStates', + supportsSSR: false +}) diff --git a/packages/e2e/react/src/routes.tsx b/packages/e2e/react/src/routes.tsx index 4daa082f8..9874d5fd2 100644 --- a/packages/e2e/react/src/routes.tsx +++ b/packages/e2e/react/src/routes.tsx @@ -14,7 +14,9 @@ const routes: Record JSX.Element>> = { '/routing/useQueryState': lazy(() => import('./routes/routing.useQueryState')), '/routing/useQueryState/other': lazy(() => import('./routes/routing.useQueryState.other')), '/routing/useQueryStates': lazy(() => import('./routes/routing.useQueryStates')), - '/routing/useQueryStates/other': lazy(() => import('./routes/routing.useQueryStates.other')) + '/routing/useQueryStates/other': lazy(() => import('./routes/routing.useQueryStates.other')), + '/shallow/useQueryState': lazy(() => import('./routes/shallow.useQueryState')), + '/shallow/useQueryStates': lazy(() => import('./routes/shallow.useQueryStates')), } export function Router() { diff --git a/packages/e2e/react/src/routes/shallow.useQueryState.tsx b/packages/e2e/react/src/routes/shallow.useQueryState.tsx new file mode 100644 index 000000000..bb8e9b1d0 --- /dev/null +++ b/packages/e2e/react/src/routes/shallow.useQueryState.tsx @@ -0,0 +1,3 @@ +import { ShallowUseQueryState } from 'e2e-shared/specs/shallow' + +export default ShallowUseQueryState diff --git a/packages/e2e/react/src/routes/shallow.useQueryStates.tsx b/packages/e2e/react/src/routes/shallow.useQueryStates.tsx new file mode 100644 index 000000000..6b95f8a86 --- /dev/null +++ b/packages/e2e/react/src/routes/shallow.useQueryStates.tsx @@ -0,0 +1,3 @@ +import { ShallowUseQueryStates } from 'e2e-shared/specs/shallow' + +export default ShallowUseQueryStates From 555eec415a5f0dfdd5c5019ea1cb9027535fbcd9 Mon Sep 17 00:00:00 2001 From: Francois Best Date: Tue, 17 Dec 2024 11:37:17 +0100 Subject: [PATCH 14/23] ref: Refactor react-router-based adapters into a shared implementation --- .../nuqs/src/adapters/lib/react-router.ts | 127 ++++++++++++++++++ packages/nuqs/src/adapters/react-router.ts | 6 +- packages/nuqs/src/adapters/react-router/v6.ts | 119 ++-------------- packages/nuqs/src/adapters/react-router/v7.ts | 119 ++-------------- packages/nuqs/src/adapters/remix.ts | 117 ++-------------- 5 files changed, 162 insertions(+), 326 deletions(-) create mode 100644 packages/nuqs/src/adapters/lib/react-router.ts diff --git a/packages/nuqs/src/adapters/lib/react-router.ts b/packages/nuqs/src/adapters/lib/react-router.ts new file mode 100644 index 000000000..e9a5d7ef3 --- /dev/null +++ b/packages/nuqs/src/adapters/lib/react-router.ts @@ -0,0 +1,127 @@ +import mitt from 'mitt' +import { + startTransition, + useCallback, + useEffect, + useLayoutEffect, + useState +} from 'react' +import { renderQueryString } from '../../url-encoding' +import type { AdapterInterface, AdapterOptions } from './defs' +import { + historyUpdateMarker, + patchHistory, + type SearchParamsSyncEmitter +} from './patch-history' + +// Abstract the types for the useNavigate hook from react-router-based frameworks +type NavigateUrl = { + hash?: string + search?: string +} +type NavigateOptions = { + replace?: boolean + preventScrollReset?: boolean +} +type NavigateFn = (url: NavigateUrl, options: NavigateOptions) => void +type UseNavigate = () => NavigateFn + +type UseSearchParams = () => [URLSearchParams, {}] + +// -- + +function getSearchParamsSnapshot() { + return new URLSearchParams(location.search) +} + +export function createReactRouterBasedAdapter( + adapter: string, + useNavigate: UseNavigate, + useSearchParams: UseSearchParams +) { + const emitter: SearchParamsSyncEmitter = mitt() + function useNuqsReactRouterBasedAdapter(): AdapterInterface { + const navigate = useNavigate() + const searchParams = useOptimisticSearchParams() + const updateUrl = useCallback( + (search: URLSearchParams, options: AdapterOptions) => { + startTransition(() => { + emitter.emit('update', search) + }) + const url = new URL(location.href) + url.search = renderQueryString(search) + // First, update the URL locally without triggering a network request, + // this allows keeping a reactive URL if the network is slow. + const updateMethod = + options.history === 'push' ? history.pushState : history.replaceState + updateMethod.call( + history, + history.state, // Maintain the history state + historyUpdateMarker, + url + ) + if (options.shallow === false) { + navigate( + { + // Somehow passing the full URL object here strips the search params + // when accessing the request.url in loaders. + hash: url.hash, + search: url.search + }, + { + replace: true, + preventScrollReset: true + } + ) + } + if (options.scroll) { + window.scrollTo(0, 0) + } + }, + [navigate] + ) + return { + searchParams, + updateUrl, + getSearchParamsSnapshot + } + } + function useOptimisticSearchParams() { + const [serverSearchParams] = useSearchParams() + const [searchParams, setSearchParams] = useState(serverSearchParams) + useEffect(() => { + function onPopState() { + setSearchParams(new URLSearchParams(location.search)) + } + function onEmitterUpdate(search: URLSearchParams) { + setSearchParams(search) + } + emitter.on('update', onEmitterUpdate) + window.addEventListener('popstate', onPopState) + return () => { + emitter.off('update', onEmitterUpdate) + window.removeEventListener('popstate', onPopState) + } + }, []) + useLayoutEffect(() => { + emitter.emit('update', serverSearchParams) + }, [serverSearchParams]) + return searchParams + } + /** + * Opt-in to syncing shallow updates of the URL with the useOptimisticSearchParams hook. + * + * By default, the useOptimisticSearchParams hook will only react to internal nuqs updates. + * If third party code updates the History API directly, use this function to + * enable useOptimisticSearchParams to react to those changes. + */ + function enableHistorySync() { + patchHistory(emitter, adapter) + } + + return { + useNuqsReactRouterBasedAdapter, + useOptimisticSearchParams, + enableHistorySync + } +} diff --git a/packages/nuqs/src/adapters/react-router.ts b/packages/nuqs/src/adapters/react-router.ts index f56d91c5e..86eb26577 100644 --- a/packages/nuqs/src/adapters/react-router.ts +++ b/packages/nuqs/src/adapters/react-router.ts @@ -6,4 +6,8 @@ * * @deprecated Please pin your version of react-router in the import: `nuqs/adapters/react-router/v6` or `nuqs/adapters/react-router/v7`. */ -export { NuqsAdapter } from './react-router/v6' +export { + enableHistorySync, + NuqsAdapter, + useOptimisticSearchParams +} from './react-router/v6' diff --git a/packages/nuqs/src/adapters/react-router/v6.ts b/packages/nuqs/src/adapters/react-router/v6.ts index 07e4309f4..7acc23d39 100644 --- a/packages/nuqs/src/adapters/react-router/v6.ts +++ b/packages/nuqs/src/adapters/react-router/v6.ts @@ -1,114 +1,17 @@ -import mitt from 'mitt' -import { - startTransition, - useCallback, - useEffect, - useLayoutEffect, - useState -} from 'react' -import { - useNavigate, - useSearchParams as useReactRouterSearchParams -} from 'react-router-dom' -import { renderQueryString } from '../../url-encoding' +import { useNavigate, useSearchParams } from 'react-router-dom' import { createAdapterProvider } from '../lib/context' -import type { AdapterInterface, AdapterOptions } from '../lib/defs' -import { - historyUpdateMarker, - patchHistory, - type SearchParamsSyncEmitter -} from '../lib/patch-history' - -const emitter: SearchParamsSyncEmitter = mitt() +import { createReactRouterBasedAdapter } from '../lib/react-router' -let searchParamsSnapshot = new URLSearchParams( - typeof location === 'object' ? location.search : '' +const { + enableHistorySync, + useNuqsReactRouterBasedAdapter: useNuqsReactRouterV6Adapter, + useOptimisticSearchParams +} = createReactRouterBasedAdapter( + 'react-router-v6', + useNavigate, + useSearchParams ) -function getSearchParamsSnapshot() { - return new URLSearchParams(location.search) -} - -function useNuqsReactRouterV6Adapter(): AdapterInterface { - const navigate = useNavigate() - const searchParams = useOptimisticSearchParams() - const updateUrl = useCallback( - (search: URLSearchParams, options: AdapterOptions) => { - startTransition(() => { - emitter.emit('update', search) - }) - const url = new URL(location.href) - url.search = renderQueryString(search) - // First, update the URL locally without triggering a network request, - // this allows keeping a reactive URL if the network is slow. - const updateMethod = - options.history === 'push' ? history.pushState : history.replaceState - updateMethod.call( - history, - history.state, // Maintain the history state - historyUpdateMarker, - url - ) - if (options.shallow === false) { - navigate( - { - // Somehow passing the full URL object here strips the search params - // when accessing the request.url in loaders. - hash: url.hash, - search: url.search - }, - { - replace: true, - preventScrollReset: true - } - ) - } - if (options.scroll) { - window.scrollTo(0, 0) - } - }, - [navigate] - ) - return { - searchParams, - updateUrl, - getSearchParamsSnapshot - } -} +export { enableHistorySync, useOptimisticSearchParams } export const NuqsAdapter = createAdapterProvider(useNuqsReactRouterV6Adapter) - -export function useOptimisticSearchParams() { - const [serverSearchParams] = useReactRouterSearchParams() - const [searchParams, setSearchParams] = useState(serverSearchParams) - useEffect(() => { - function onPopState() { - setSearchParams(new URLSearchParams(location.search)) - } - function onEmitterUpdate(search: URLSearchParams) { - setSearchParams(search) - searchParamsSnapshot = search - } - emitter.on('update', onEmitterUpdate) - window.addEventListener('popstate', onPopState) - return () => { - emitter.off('update', onEmitterUpdate) - window.removeEventListener('popstate', onPopState) - } - }, []) - useLayoutEffect(() => { - emitter.emit('update', serverSearchParams) - }, [serverSearchParams]) - return searchParams -} - -/** - * Opt-in to syncing shallow updates of the URL with the useOptimisticSearchParams hook. - * - * By default, the useOptimisticSearchParams hook will only react to internal nuqs updates. - * If third party code updates the History API directly, use this function to - * enable useOptimisticSearchParams to react to those changes. - */ -export function enableHistorySync() { - patchHistory(emitter, 'react-router-v6') -} diff --git a/packages/nuqs/src/adapters/react-router/v7.ts b/packages/nuqs/src/adapters/react-router/v7.ts index 5423e01b0..1c46bb41c 100644 --- a/packages/nuqs/src/adapters/react-router/v7.ts +++ b/packages/nuqs/src/adapters/react-router/v7.ts @@ -1,114 +1,17 @@ -import mitt from 'mitt' -import { - startTransition, - useCallback, - useEffect, - useLayoutEffect, - useState -} from 'react' -import { - useNavigate, - useSearchParams as useReactRouterSearchParams -} from 'react-router' -import { renderQueryString } from '../../url-encoding' +import { useNavigate, useSearchParams } from 'react-router' import { createAdapterProvider } from '../lib/context' -import type { AdapterInterface, AdapterOptions } from '../lib/defs' -import { - historyUpdateMarker, - patchHistory, - type SearchParamsSyncEmitter -} from '../lib/patch-history' - -const emitter: SearchParamsSyncEmitter = mitt() +import { createReactRouterBasedAdapter } from '../lib/react-router' -let searchParamsSnapshot = new URLSearchParams( - typeof location === 'object' ? location.search : '' +const { + enableHistorySync, + useNuqsReactRouterBasedAdapter: useNuqsReactRouterV7Adapter, + useOptimisticSearchParams +} = createReactRouterBasedAdapter( + 'react-router-v7', + useNavigate, + useSearchParams ) -function getSearchParamsSnapshot() { - return new URLSearchParams(location.search) -} - -function useNuqsReactRouterV7Adapter(): AdapterInterface { - const navigate = useNavigate() - const searchParams = useOptimisticSearchParams() - const updateUrl = useCallback( - (search: URLSearchParams, options: AdapterOptions) => { - startTransition(() => { - emitter.emit('update', search) - }) - const url = new URL(location.href) - url.search = renderQueryString(search) - // First, update the URL locally without triggering a network request, - // this allows keeping a reactive URL if the network is slow. - const updateMethod = - options.history === 'push' ? history.pushState : history.replaceState - updateMethod.call( - history, - history.state, // Maintain the history state - historyUpdateMarker, - url - ) - if (options.shallow === false) { - navigate( - { - // Somehow passing the full URL object here strips the search params - // when accessing the request.url in loaders. - hash: url.hash, - search: url.search - }, - { - replace: true, - preventScrollReset: true - } - ) - } - if (options.scroll) { - window.scrollTo(0, 0) - } - }, - [navigate] - ) - return { - searchParams, - updateUrl, - getSearchParamsSnapshot - } -} +export { enableHistorySync, useOptimisticSearchParams } export const NuqsAdapter = createAdapterProvider(useNuqsReactRouterV7Adapter) - -export function useOptimisticSearchParams() { - const [serverSearchParams] = useReactRouterSearchParams() - const [searchParams, setSearchParams] = useState(serverSearchParams) - useEffect(() => { - function onPopState() { - setSearchParams(new URLSearchParams(location.search)) - } - function onEmitterUpdate(search: URLSearchParams) { - setSearchParams(search) - searchParamsSnapshot = search - } - emitter.on('update', onEmitterUpdate) - window.addEventListener('popstate', onPopState) - return () => { - emitter.off('update', onEmitterUpdate) - window.removeEventListener('popstate', onPopState) - } - }, []) - useLayoutEffect(() => { - emitter.emit('update', serverSearchParams) - }, [serverSearchParams]) - return searchParams -} - -/** - * Opt-in to syncing shallow updates of the URL with the useOptimisticSearchParams hook. - * - * By default, the useOptimisticSearchParams hook will only react to internal nuqs updates. - * If third party code updates the History API directly, use this function to - * enable useOptimisticSearchParams to react to those changes. - */ -export function enableHistorySync() { - patchHistory(emitter, 'react-router-v7') -} diff --git a/packages/nuqs/src/adapters/remix.ts b/packages/nuqs/src/adapters/remix.ts index e7aa57d4e..db7358860 100644 --- a/packages/nuqs/src/adapters/remix.ts +++ b/packages/nuqs/src/adapters/remix.ts @@ -1,114 +1,13 @@ -import { - useNavigate, - useSearchParams as useRemixSearchParams -} from '@remix-run/react' -import mitt from 'mitt' -import { - startTransition, - useCallback, - useEffect, - useLayoutEffect, - useState -} from 'react' -import { renderQueryString } from '../url-encoding' +import { useNavigate, useSearchParams } from '@remix-run/react' import { createAdapterProvider } from './lib/context' -import type { AdapterInterface, AdapterOptions } from './lib/defs' -import { - historyUpdateMarker, - patchHistory, - type SearchParamsSyncEmitter -} from './lib/patch-history' +import { createReactRouterBasedAdapter } from './lib/react-router' -const emitter: SearchParamsSyncEmitter = mitt() +const { + enableHistorySync, + useNuqsReactRouterBasedAdapter: useNuqsRemixAdapter, + useOptimisticSearchParams +} = createReactRouterBasedAdapter('remix', useNavigate, useSearchParams) -let searchParamsSnapshot = new URLSearchParams( - typeof location === 'object' ? location.search : '' -) - -function getSearchParamsSnapshot() { - return new URLSearchParams(location.search) -} - -function useNuqsRemixAdapter(): AdapterInterface { - const navigate = useNavigate() - const searchParams = useOptimisticSearchParams() - const updateUrl = useCallback( - (search: URLSearchParams, options: AdapterOptions) => { - startTransition(() => { - emitter.emit('update', search) - }) - const url = new URL(location.href) - url.search = renderQueryString(search) - // First, update the URL locally without triggering a network request, - // this allows keeping a reactive URL if the network is slow. - const updateMethod = - options.history === 'push' ? history.pushState : history.replaceState - updateMethod.call( - history, - history.state, // Maintain the history state - historyUpdateMarker, - url - ) - if (options.shallow === false) { - navigate( - { - // Somehow passing the full URL object here strips the search params - // when accessing the request.url in loaders. - hash: url.hash, - search: url.search - }, - { - replace: true, - preventScrollReset: true - } - ) - } - if (options.scroll) { - window.scrollTo(0, 0) - } - }, - [navigate] - ) - return { - searchParams, - updateUrl, - getSearchParamsSnapshot - } -} +export { enableHistorySync, useOptimisticSearchParams } export const NuqsAdapter = createAdapterProvider(useNuqsRemixAdapter) - -export function useOptimisticSearchParams() { - const [serverSearchParams] = useRemixSearchParams() - const [searchParams, setSearchParams] = useState(serverSearchParams) - useEffect(() => { - function onPopState() { - setSearchParams(new URLSearchParams(location.search)) - } - function onEmitterUpdate(search: URLSearchParams) { - setSearchParams(search) - searchParamsSnapshot = search - } - emitter.on('update', onEmitterUpdate) - window.addEventListener('popstate', onPopState) - return () => { - emitter.off('update', onEmitterUpdate) - window.removeEventListener('popstate', onPopState) - } - }, []) - useLayoutEffect(() => { - emitter.emit('update', serverSearchParams) - }, [serverSearchParams]) - return searchParams -} - -/** - * Opt-in to syncing shallow updates of the URL with the useOptimisticSearchParams hook. - * - * By default, the useOptimisticSearchParams hook will only react to internal nuqs updates. - * If third party code updates the History API directly, use this function to - * enable useOptimisticSearchParams to react to those changes. - */ -export function enableHistorySync() { - patchHistory(emitter, 'remix') -} From aaa54b0b8d231ad2316924a1b645aa5f736c4a10 Mon Sep 17 00:00:00 2001 From: Francois Best Date: Tue, 17 Dec 2024 13:39:07 +0100 Subject: [PATCH 15/23] chore: Remove default getSearchParamsSnapshot --- packages/nuqs/src/adapters/lib/react-router.ts | 9 ++------- packages/nuqs/src/update-queue.ts | 6 +++++- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/nuqs/src/adapters/lib/react-router.ts b/packages/nuqs/src/adapters/lib/react-router.ts index e9a5d7ef3..2a5a937a9 100644 --- a/packages/nuqs/src/adapters/lib/react-router.ts +++ b/packages/nuqs/src/adapters/lib/react-router.ts @@ -14,7 +14,7 @@ import { type SearchParamsSyncEmitter } from './patch-history' -// Abstract the types for the useNavigate hook from react-router-based frameworks +// Abstract away the types for the useNavigate hook from react-router-based frameworks type NavigateUrl = { hash?: string search?: string @@ -30,10 +30,6 @@ type UseSearchParams = () => [URLSearchParams, {}] // -- -function getSearchParamsSnapshot() { - return new URLSearchParams(location.search) -} - export function createReactRouterBasedAdapter( adapter: string, useNavigate: UseNavigate, @@ -82,8 +78,7 @@ export function createReactRouterBasedAdapter( ) return { searchParams, - updateUrl, - getSearchParamsSnapshot + updateUrl } } function useOptimisticSearchParams() { diff --git a/packages/nuqs/src/update-queue.ts b/packages/nuqs/src/update-queue.ts index e396f99d0..5971362a3 100644 --- a/packages/nuqs/src/update-queue.ts +++ b/packages/nuqs/src/update-queue.ts @@ -66,6 +66,10 @@ export function enqueueQueryStringUpdate( return serializedOrNull } +function getSearchParamsSnapshotFromLocation() { + return new URLSearchParams(location.search) +} + /** * Eventually flush the update queue to the URL query string. * @@ -77,7 +81,7 @@ export function enqueueQueryStringUpdate( * @returns a Promise to the URLSearchParams that have been applied. */ export function scheduleFlushToURL({ - getSearchParamsSnapshot = () => new URLSearchParams(location.search), + getSearchParamsSnapshot = getSearchParamsSnapshotFromLocation, updateUrl, rateLimitFactor = 1 }: Pick< From 578890b88d6dbed3f3520e8a91dded3bf604f0cf Mon Sep 17 00:00:00 2001 From: Francois Best Date: Tue, 17 Dec 2024 13:58:53 +0100 Subject: [PATCH 16/23] doc: Distinguish RRv6 & RRv7 in docs --- README.md | 29 ++++++++++++++++-- errors/NUQS-404.md | 3 +- packages/docs/content/docs/adapters.mdx | 33 ++++++++++++++++++--- packages/docs/content/docs/installation.mdx | 3 +- 4 files changed, 59 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index db50ff553..356d7b35d 100644 --- a/README.md +++ b/README.md @@ -119,13 +119,13 @@ export default function App() { -
React Router +
React Router v6 -> Supported React Router versions: `react-router-dom@>=6` +> Supported React Router versions: `react-router-dom@^6` ```tsx -import { NuqsAdapter } from 'nuqs/adapters/react-router' +import { NuqsAdapter } from 'nuqs/adapters/react-router/v6' import { createBrowserRouter, RouterProvider } from 'react-router-dom' import App from './App' @@ -147,6 +147,29 @@ export function ReactRouter() {
+
React Router v7 + + +> Supported React Router versions: `react-router@^7` + +```tsx +// app/root.tsx +import { NuqsAdapter } from 'nuqs/adapters/react-router/v7' +import { Outlet } from 'react-router' + +// ... + +export default function App() { + return ( + + + + ) +} +``` + +
+ ## Usage ```tsx diff --git a/errors/NUQS-404.md b/errors/NUQS-404.md index e0dc39f31..a91ea26c4 100644 --- a/errors/NUQS-404.md +++ b/errors/NUQS-404.md @@ -18,7 +18,8 @@ using a suitable adapter: - [Next.js (pages router)](https://nuqs.47ng.com/docs/adapters#nextjs-pages-router) - [React SPA (eg: with Vite)](https://nuqs.47ng.com/docs/adapters#react-spa) - [Remix](https://nuqs.47ng.com/docs/adapters#remix) -- [React Router](https://nuqs.47ng.com/docs/adapters#react-router) +- [React Router v6](https://nuqs.47ng.com/docs/adapters#react-router-v6) +- [React Router v7](https://nuqs.47ng.com/docs/adapters#react-router-v7) ### Test adapter diff --git a/packages/docs/content/docs/adapters.mdx b/packages/docs/content/docs/adapters.mdx index 0e69f30df..6ec4adf04 100644 --- a/packages/docs/content/docs/adapters.mdx +++ b/packages/docs/content/docs/adapters.mdx @@ -10,7 +10,8 @@ wrapping it with a `NuqsAdapter` context provider: - [Next.js (pages router)](#nextjs-pages-router) - [React SPA (eg: with Vite)](#react-spa) - [Remix](#remix) -- [React Router](#react-router) +- [React Router v6](#react-router-v6) +- [React Router v7](#react-router-v7) ## Next.js @@ -102,11 +103,11 @@ export default function App() { } ``` -## React Router +## React Router v6 ```tsx title="src/main.tsx" // [!code word:NuqsAdapter] -import { NuqsAdapter } from 'nuqs/adapters/react-router' +import { NuqsAdapter } from 'nuqs/adapters/react-router/v6' import { createBrowserRouter, RouterProvider } from 'react-router-dom' import App from './App' @@ -126,7 +127,31 @@ export function ReactRouter() { } ``` -**Note**: If you are using react-router v7, please import the `NuqsAdapter{:ts}` from `nuqs/adapters/react-router/v7` +## React Router v7 + +```tsx title="app/root.tsx" +// [!code word:NuqsAdapter] +import { NuqsAdapter } from 'nuqs/adapters/react-router/v7' +import { Outlet } from 'react-router' + +// ... + +export default function App() { + return ( + + + + ) +} +``` + + + The generic import `nuqs/adapters/react-router` (pointing to v6) + is deprecated and will be removed in nuqs@3.0.0. + + Please pin your imports to the specific version, + eg: `nuqs/adapters/react-router/v6` or `nuqs/adapters/react-router/v7`. + ## Testing diff --git a/packages/docs/content/docs/installation.mdx b/packages/docs/content/docs/installation.mdx index cfef17d23..b193884ea 100644 --- a/packages/docs/content/docs/installation.mdx +++ b/packages/docs/content/docs/installation.mdx @@ -28,7 +28,8 @@ bun add nuqs - [Next.js](./adapters#nextjs): 14.2.0 and above (including Next.js 15) - [React SPA](./adapters#react-spa): 18.3.0 & 19 RC - [Remix](./adapters#remix): 2 and above -- [React Router](./adapters#react-router): 6 and above +- [React Router v6](./adapters#react-router-v6): `react-router-dom@^6` +- [React Router v7](./adapters#react-router-v7): `react-router@^7` For older versions of Next.js, you may use `nuqs@^1` (documentation in the README). From 91b823b6b87394821ed01fe7e68ddf9812bb72f0 Mon Sep 17 00:00:00 2001 From: Francois Best Date: Tue, 17 Dec 2024 14:13:02 +0100 Subject: [PATCH 17/23] doc: Deprecate the re-exported imports --- packages/nuqs/src/adapters/react-router.ts | 35 +++++++++++++++++----- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/packages/nuqs/src/adapters/react-router.ts b/packages/nuqs/src/adapters/react-router.ts index 86eb26577..ed41f1588 100644 --- a/packages/nuqs/src/adapters/react-router.ts +++ b/packages/nuqs/src/adapters/react-router.ts @@ -1,13 +1,32 @@ -/** - * Note: this default react-router adapter is for react-router v6. - * If you are using react-router v7, please import from `nuqs/adapters/react-router/v7` - * - * This shorthand import will be removed in nuqs@3.0.0. - * - * @deprecated Please pin your version of react-router in the import: `nuqs/adapters/react-router/v6` or `nuqs/adapters/react-router/v7`. - */ export { + /** + * @deprecated This import will be removed in nuqs@3.0.0. + * + * Please pin your version of React Router in the import: + * - `nuqs/adapters/react-router/v6` + * - `nuqs/adapters/react-router/v7`. + * + * Note: this deprecated import (`nuqs/adapters/react-router`) is for React Router v6 only. + */ enableHistorySync, + /** + * @deprecated This import will be removed in nuqs@3.0.0. + * + * Please pin your version of React Router in the import: + * - `nuqs/adapters/react-router/v6` + * - `nuqs/adapters/react-router/v7`. + * + * Note: this deprecated import (`nuqs/adapters/react-router`) is for React Router v6 only. + */ NuqsAdapter, + /** + * @deprecated This import will be removed in nuqs@3.0.0. + * + * Please pin your version of React Router in the import: + * - `nuqs/adapters/react-router/v6` + * - `nuqs/adapters/react-router/v7`. + * + * Note: this deprecated import (`nuqs/adapters/react-router`) is for React Router v6 only. + */ useOptimisticSearchParams } from './react-router/v6' From 2ce1ec8ff38a2fa38c36fe57c67ff2dba5091808 Mon Sep 17 00:00:00 2001 From: Francois Best Date: Tue, 17 Dec 2024 14:42:34 +0100 Subject: [PATCH 18/23] feat: History API sync for React --- .../e2e/react/cypress/e2e/shared/routing.cy.ts | 6 ++---- packages/e2e/react/src/main.tsx | 4 +++- packages/nuqs/src/adapters/react.ts | 14 +++++++++++++- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/e2e/react/cypress/e2e/shared/routing.cy.ts b/packages/e2e/react/cypress/e2e/shared/routing.cy.ts index 7f84e065e..9c9e93bb3 100644 --- a/packages/e2e/react/cypress/e2e/shared/routing.cy.ts +++ b/packages/e2e/react/cypress/e2e/shared/routing.cy.ts @@ -2,12 +2,10 @@ import { testRouting } from 'e2e-shared/specs/routing.cy' testRouting({ path: '/routing/useQueryState', - hook: 'useQueryState', - shallowOptions: [false] // todo: Enable shallow routing + hook: 'useQueryState' }) testRouting({ path: '/routing/useQueryStates', - hook: 'useQueryStates', - shallowOptions: [false] // todo: Enable shallow routing + hook: 'useQueryStates' }) diff --git a/packages/e2e/react/src/main.tsx b/packages/e2e/react/src/main.tsx index 7f99db9d0..25fe54c2b 100644 --- a/packages/e2e/react/src/main.tsx +++ b/packages/e2e/react/src/main.tsx @@ -1,9 +1,11 @@ -import { NuqsAdapter } from 'nuqs/adapters/react' +import { NuqsAdapter, enableHistorySync } from 'nuqs/adapters/react' import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { RootLayout } from './layout' import { Router } from './routes' +enableHistorySync() + createRoot(document.getElementById('root')!).render( diff --git a/packages/nuqs/src/adapters/react.ts b/packages/nuqs/src/adapters/react.ts index 66b4889c2..33351b490 100644 --- a/packages/nuqs/src/adapters/react.ts +++ b/packages/nuqs/src/adapters/react.ts @@ -3,8 +3,9 @@ import { useEffect, useState } from 'react' import { renderQueryString } from '../url-encoding' import { createAdapterProvider } from './lib/context' import type { AdapterOptions } from './lib/defs' +import { patchHistory, type SearchParamsSyncEmitter } from './lib/patch-history' -const emitter = mitt<{ update: URLSearchParams }>() +const emitter: SearchParamsSyncEmitter = mitt() function updateUrl(search: URLSearchParams, options: AdapterOptions) { const url = new URL(location.href) @@ -42,3 +43,14 @@ function useNuqsReactAdapter() { } export const NuqsAdapter = createAdapterProvider(useNuqsReactAdapter) + +/** + * Opt-in to syncing shallow updates of the URL with the useOptimisticSearchParams hook. + * + * By default, the useOptimisticSearchParams hook will only react to internal nuqs updates. + * If third party code updates the History API directly, use this function to + * enable useOptimisticSearchParams to react to those changes. + */ +export function enableHistorySync() { + patchHistory(emitter, 'react') +} From 602dbbdebdcfc3ea61921f30a0e616a0fea1e848 Mon Sep 17 00:00:00 2001 From: Francois Best Date: Tue, 17 Dec 2024 14:43:28 +0100 Subject: [PATCH 19/23] feat: Enable History sync for React Routers --- .../e2e/react-router/v6/cypress/e2e/shared/routing.cy.ts | 6 ++---- packages/e2e/react-router/v6/src/react-router.tsx | 4 +++- packages/e2e/react-router/v7/app/root.tsx | 4 +++- .../e2e/react-router/v7/cypress/e2e/shared/routing.cy.ts | 6 ++---- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/e2e/react-router/v6/cypress/e2e/shared/routing.cy.ts b/packages/e2e/react-router/v6/cypress/e2e/shared/routing.cy.ts index 7f84e065e..9c9e93bb3 100644 --- a/packages/e2e/react-router/v6/cypress/e2e/shared/routing.cy.ts +++ b/packages/e2e/react-router/v6/cypress/e2e/shared/routing.cy.ts @@ -2,12 +2,10 @@ import { testRouting } from 'e2e-shared/specs/routing.cy' testRouting({ path: '/routing/useQueryState', - hook: 'useQueryState', - shallowOptions: [false] // todo: Enable shallow routing + hook: 'useQueryState' }) testRouting({ path: '/routing/useQueryStates', - hook: 'useQueryStates', - shallowOptions: [false] // todo: Enable shallow routing + 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 7efcf48fe..bae460f1e 100644 --- a/packages/e2e/react-router/v6/src/react-router.tsx +++ b/packages/e2e/react-router/v6/src/react-router.tsx @@ -1,4 +1,4 @@ -import { NuqsAdapter } from 'nuqs/adapters/react-router/v6' +import { enableHistorySync, NuqsAdapter } from 'nuqs/adapters/react-router/v6' import { createBrowserRouter, createRoutesFromElements, @@ -7,6 +7,8 @@ import { } from 'react-router-dom' import RootLayout from './layout' +enableHistorySync() + // Adapt the RRv7 / Remix default export for component into a Component export for v6 function load(mod: Promise<{ default: any; [otherExports: string]: any }>) { return () => diff --git a/packages/e2e/react-router/v7/app/root.tsx b/packages/e2e/react-router/v7/app/root.tsx index 9344e74ae..c5bb72410 100644 --- a/packages/e2e/react-router/v7/app/root.tsx +++ b/packages/e2e/react-router/v7/app/root.tsx @@ -1,4 +1,4 @@ -import { NuqsAdapter } from 'nuqs/adapters/react-router/v7' +import { enableHistorySync, NuqsAdapter } from 'nuqs/adapters/react-router/v7' import { isRouteErrorResponse, Links, @@ -8,6 +8,8 @@ import { ScrollRestoration } from 'react-router' +enableHistorySync() + import type { Route } from './+types/root' export function Layout({ children }: { children: React.ReactNode }) { diff --git a/packages/e2e/react-router/v7/cypress/e2e/shared/routing.cy.ts b/packages/e2e/react-router/v7/cypress/e2e/shared/routing.cy.ts index 7f84e065e..9c9e93bb3 100644 --- a/packages/e2e/react-router/v7/cypress/e2e/shared/routing.cy.ts +++ b/packages/e2e/react-router/v7/cypress/e2e/shared/routing.cy.ts @@ -2,12 +2,10 @@ import { testRouting } from 'e2e-shared/specs/routing.cy' testRouting({ path: '/routing/useQueryState', - hook: 'useQueryState', - shallowOptions: [false] // todo: Enable shallow routing + hook: 'useQueryState' }) testRouting({ path: '/routing/useQueryStates', - hook: 'useQueryStates', - shallowOptions: [false] // todo: Enable shallow routing + hook: 'useQueryStates' }) From e51a41d880ae615ef9baa7495d6a14b78eb2c721 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Best?= Date: Tue, 17 Dec 2024 15:57:45 +0100 Subject: [PATCH 20/23] doc: Shallow docs --- packages/docs/content/docs/options.mdx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/docs/content/docs/options.mdx b/packages/docs/content/docs/options.mdx index 18dbd1e42..8f64ce132 100644 --- a/packages/docs/content/docs/options.mdx +++ b/packages/docs/content/docs/options.mdx @@ -64,14 +64,22 @@ no network calls to the server. This is equivalent to the `shallow` option of the Next.js router set to `true{:ts}`. -To opt-in to query updates notifying the server (to re-render Server Components -on the app router and re-run `getServerSideProps` in the pages router), -you can set `shallow` to `false{:ts}`: +To opt-in to notifying the server on query updates, you can set `shallow` to `false{:ts}`: ```ts /shallow: false/ useQueryState('foo', { shallow: false }) ``` +Note that the shallow option only makes sense if your page can be server-side rendered. +Therefore, it's a no-op in React SPA. + +For server-side renderable frameworks, you would pair `shallow: false{:ts}` with: + +- In Next.js app router: the `searchParams` page prop to render the RSC tree based on the updated query state. +- In Next.js pages router: the `getServerSideProps` function +- In Remix & React Router: a `loader` function + + ## Scroll From 7e6e6de144b45a5e4b140b97418c06ae61939d9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Best?= Date: Tue, 17 Dec 2024 16:20:03 +0100 Subject: [PATCH 21/23] test: Fail fast when running locally --- packages/e2e/shared/cypress.config.ts | 4 ++-- packages/e2e/shared/package.json | 1 + packages/e2e/shared/tsconfig.json | 2 +- pnpm-lock.yaml | 3 +++ 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/e2e/shared/cypress.config.ts b/packages/e2e/shared/cypress.config.ts index dfc1dfe78..072ecd9b7 100644 --- a/packages/e2e/shared/cypress.config.ts +++ b/packages/e2e/shared/cypress.config.ts @@ -12,13 +12,13 @@ export function defineConfig(config: Config) { video: false, fixturesFolder: false, testIsolation: true, - defaultCommandTimeout: 500, + defaultCommandTimeout: process.env.CI ? 1000 : 200, setupNodeEvents(on) { cypressTerminalReport(on) }, retries: { openMode: 0, - runMode: 1 + runMode: process.env.CI ? 1 : 0 }, ...config } diff --git a/packages/e2e/shared/package.json b/packages/e2e/shared/package.json index 9200619ee..7faddb3ac 100644 --- a/packages/e2e/shared/package.json +++ b/packages/e2e/shared/package.json @@ -19,6 +19,7 @@ "devDependencies": { "@types/react": "catalog:react19", "@types/react-dom": "catalog:react19", + "@types/node": "^22.9.0", "cypress": "catalog:e2e", "nuqs": "workspace:*", "react": "catalog:react19", diff --git a/packages/e2e/shared/tsconfig.json b/packages/e2e/shared/tsconfig.json index 1bd1113d1..ff82a4685 100644 --- a/packages/e2e/shared/tsconfig.json +++ b/packages/e2e/shared/tsconfig.json @@ -24,7 +24,7 @@ // Misc "skipLibCheck": true, "skipDefaultLibCheck": true, - "types": ["cypress"] + "types": ["node", "cypress"] }, "include": ["**/*.ts", "**/*.tsx"], "exclude": ["node_modules"] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 16a248da6..59da8c0d6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -485,6 +485,9 @@ importers: specifier: ^7.0.4 version: 7.0.4(cypress@13.15.2) devDependencies: + '@types/node': + specifier: ^22.9.0 + version: 22.9.0 '@types/react': specifier: catalog:react19 version: 19.0.0 From 064d6f637078d340beef1f1fe4894c33beb16e7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Best?= Date: Tue, 17 Dec 2024 16:21:18 +0100 Subject: [PATCH 22/23] chore: Don't slowdown CI too much though --- packages/e2e/shared/cypress.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/e2e/shared/cypress.config.ts b/packages/e2e/shared/cypress.config.ts index 072ecd9b7..52febf41d 100644 --- a/packages/e2e/shared/cypress.config.ts +++ b/packages/e2e/shared/cypress.config.ts @@ -12,7 +12,7 @@ export function defineConfig(config: Config) { video: false, fixturesFolder: false, testIsolation: true, - defaultCommandTimeout: process.env.CI ? 1000 : 200, + defaultCommandTimeout: process.env.CI ? 500 : 200, setupNodeEvents(on) { cypressTerminalReport(on) }, From acbdf285ffa30e9c857e9f04299e3ceb968e0b3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Best?= Date: Tue, 17 Dec 2024 17:12:46 +0100 Subject: [PATCH 23/23] doc: Point to useOptimisticSearchParams & enableHistorySync --- packages/docs/content/docs/options.mdx | 43 ++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/packages/docs/content/docs/options.mdx b/packages/docs/content/docs/options.mdx index 8f64ce132..d6dae49e3 100644 --- a/packages/docs/content/docs/options.mdx +++ b/packages/docs/content/docs/options.mdx @@ -79,7 +79,50 @@ For server-side renderable frameworks, you would pair `shallow: false{:ts}` with - In Next.js pages router: the `getServerSideProps` function - In Remix & React Router: a `loader` function +### In React Router based frameworks +While the `shallow: false` default behaviour is uncommon for Remix and React Router, +where loaders are always supposed to run on URL changes, nuqs gives you control +of this behaviour, by opting in to running loaders only if they do need to access +the relevant search params. + +One caveat is that the stock `useSearchParams` hook from those frameworks doesn't +reflect shallow-updated search params, so we provide you with one that does: + +```tsx +import { useOptimisticSearchParams } from 'nuqs/adapters/remix' // or '…/react-router/v6' or '…/react-router/v7' + +function Component() { + // Note: this is read-only, but reactive to all URL changes + const searchParams = useOptimisticSearchParams() + return
{searchParams.get('foo')}
+} +``` + +This concept of _"shallow routing"_ is done via updates to the browser's +[History API](https://developer.mozilla.org/en-US/docs/Web/API/History_API/Working_with_the_History_API). + +While the `useOptimisticSearchParams` and the adapter itself can handle shallow URL +updates triggered from state updater functions, for them to react to URL changes +triggered by explicit calls to the History API (either by first or third party code), +you'd have to enable sync: + +```tsx +// Export available in: +// 'nuqs/adapters/remix' +// 'nuqs/adapters/react-router/v6' +// 'nuqs/adapters/react-router/v7' +// 'nuqs/adapters/react' +import { enableHistorySync } from 'nuqs/adapters/remix' + +// Somewhere top-level (like app/root.tsx) +enableHistorySync() +``` + +Note that you may not need this if only using your framework's router. + +It is opt-in as it patches the History APIs, which can have side effects +if third party code does it too. ## Scroll