From abbbb569d1307b8cb785099cd65fff28a4557b87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Best?= Date: Tue, 17 Dec 2024 21:10:09 +0100 Subject: [PATCH] feat: Shallow routing for everyone (#811) --- .vscode/settings.json | 3 + README.md | 29 ++++- errors/NUQS-404.md | 3 +- packages/docs/content/docs/adapters.mdx | 33 ++++- packages/docs/content/docs/installation.mdx | 3 +- packages/docs/content/docs/options.mdx | 57 +++++++- .../e2e/next/cypress/e2e/shared/shallow.cy.ts | 25 ++++ .../(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 ++++ .../v6/cypress/e2e/shared/routing.cy.ts | 6 +- .../v6/cypress/e2e/shared/shallow.cy.ts | 11 ++ .../e2e/react-router/v6/src/react-router.tsx | 14 +- .../v6/src/routes/shallow.useQueryState.tsx | 20 +++ .../v6/src/routes/shallow.useQueryStates.tsx | 20 +++ packages/e2e/react-router/v7/app/root.tsx | 4 +- 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/routing.cy.ts | 6 +- .../v7/cypress/e2e/shared/shallow.cy.ts | 11 ++ .../react/cypress/e2e/shared/routing.cy.ts | 6 +- .../react/cypress/e2e/shared/shallow.cy.ts | 13 ++ packages/e2e/react/src/main.tsx | 4 +- packages/e2e/react/src/routes.tsx | 4 +- .../src/routes/shallow.useQueryState.tsx | 3 + .../src/routes/shallow.useQueryStates.tsx | 3 + packages/e2e/remix/app/root.tsx | 4 +- .../app/routes/shallow.useQueryState.tsx | 21 +++ .../app/routes/shallow.useQueryStates.tsx | 21 +++ .../remix/cypress/e2e/shared/routing.cy.ts | 6 +- .../remix/cypress/e2e/shared/shallow.cy.ts | 11 ++ packages/e2e/remix/package.json | 2 +- packages/e2e/shared/cypress.config.ts | 6 +- packages/e2e/shared/package.json | 1 + packages/e2e/shared/specs/routing.cy.ts | 2 +- packages/e2e/shared/specs/shallow-display.tsx | 8 ++ packages/e2e/shared/specs/shallow.cy.ts | 51 ++++++++ packages/e2e/shared/specs/shallow.defs.ts | 8 ++ packages/e2e/shared/specs/shallow.tsx | 36 ++++++ packages/e2e/shared/tsconfig.json | 2 +- packages/nuqs/package.json | 2 +- packages/nuqs/src/adapters/custom.ts | 10 +- .../{internal.context.ts => lib/context.ts} | 2 +- packages/nuqs/src/adapters/{ => lib}/defs.ts | 2 +- .../src/adapters/lib/patch-history.test.ts | 31 +++++ .../nuqs/src/adapters/lib/patch-history.ts | 87 +++++++++++++ .../nuqs/src/adapters/lib/react-router.ts | 122 ++++++++++++++++++ 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 | 61 ++++----- packages/nuqs/src/adapters/react-router/v6.ts | 36 ++---- packages/nuqs/src/adapters/react-router/v7.ts | 36 ++---- packages/nuqs/src/adapters/react.ts | 18 ++- packages/nuqs/src/adapters/remix.ts | 32 ++--- packages/nuqs/src/adapters/testing.ts | 4 +- packages/nuqs/src/errors.ts | 2 +- packages/nuqs/src/update-queue.ts | 8 +- packages/nuqs/src/useQueryState.ts | 2 +- packages/nuqs/src/useQueryStates.ts | 2 +- pnpm-lock.yaml | 3 + 65 files changed, 935 insertions(+), 164 deletions(-) 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/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 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 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 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 create mode 100644 packages/e2e/shared/specs/shallow-display.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.tsx rename packages/nuqs/src/adapters/{internal.context.ts => lib/context.ts} (96%) rename packages/nuqs/src/adapters/{ => lib}/defs.ts (90%) create mode 100644 packages/nuqs/src/adapters/lib/patch-history.test.ts create mode 100644 packages/nuqs/src/adapters/lib/patch-history.ts create mode 100644 packages/nuqs/src/adapters/lib/react-router.ts 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 ] } 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). diff --git a/packages/docs/content/docs/options.mdx b/packages/docs/content/docs/options.mdx index 18dbd1e42..d6dae49e3 100644 --- a/packages/docs/content/docs/options.mdx +++ b/packages/docs/content/docs/options.mdx @@ -64,14 +64,65 @@ 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 + +### 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 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..382b04731 --- /dev/null +++ b/packages/e2e/next/cypress/e2e/shared/shallow.cy.ts @@ -0,0 +1,25 @@ +import { testShallow } from 'e2e-shared/specs/shallow.cy' + +testShallow({ + path: '/app/shallow/useQueryState', + hook: 'useQueryState', + nextJsRouter: 'app' +}) + +testShallow({ + path: '/app/shallow/useQueryStates', + hook: 'useQueryStates', + nextJsRouter: 'app' +}) + +testShallow({ + path: '/pages/shallow/useQueryState', + hook: 'useQueryState', + nextJsRouter: 'pages' +}) + +testShallow({ + path: '/pages/shallow/useQueryStates', + hook: 'useQueryStates', + 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..3c5d4f82c --- /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-display' +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..221be41f4 --- /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-display' +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..6d440b4f4 --- /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-display' +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..83196ccac --- /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-display' +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/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/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..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,9 +7,15 @@ 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 }>) { - 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 +35,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/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/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/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' }) 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/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/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/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/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 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/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/routing.cy.ts b/packages/e2e/remix/cypress/e2e/shared/routing.cy.ts index 7f84e065e..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: [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/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/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/e2e/shared/cypress.config.ts b/packages/e2e/shared/cypress.config.ts index 49c8f8e28..52febf41d 100644 --- a/packages/e2e/shared/cypress.config.ts +++ b/packages/e2e/shared/cypress.config.ts @@ -12,10 +12,14 @@ export function defineConfig(config: Config) { video: false, fixturesFolder: false, testIsolation: true, + defaultCommandTimeout: process.env.CI ? 500 : 200, setupNodeEvents(on) { cypressTerminalReport(on) }, - retries: 2, + retries: { + openMode: 0, + 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/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/e2e/shared/specs/shallow-display.tsx b/packages/e2e/shared/specs/shallow-display.tsx new file mode 100644 index 000000000..90a71c8d7 --- /dev/null +++ b/packages/e2e/shared/specs/shallow-display.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.cy.ts b/packages/e2e/shared/specs/shallow.cy.ts new file mode 100644 index 000000000..938140ba6 --- /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 & { + supportsSSR?: boolean + shallowOptions?: boolean[] + historyOptions?: ('replace' | 'push')[] +} + +export function testShallow({ + supportsSSR = true, + 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 (supportsSSR) { + cy.get('#server-state').should('be.empty') + } + cy.get('button').click() + cy.get('#client-state').should('have.text', 'pass') + if (supportsSSR) { + if (shallow === false) { + cy.get('#server-state').should('have.text', 'pass') + } else { + cy.get('#server-state').should('be.empty') + } + } + if (history !== 'push') { + return + } + cy.go('back') + cy.get('#client-state').should('be.empty') + if (supportsSSR) { + cy.get('#server-state').should('be.empty') + } + }) + } + } + }) + if (supportsSSR) { + 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.tsx b/packages/e2e/shared/specs/shallow.tsx new file mode 100644 index 000000000..c7d6d96e2 --- /dev/null +++ b/packages/e2e/shared/specs/shallow.tsx @@ -0,0 +1,36 @@ +'use client' + +import { parseAsString, useQueryState, useQueryStates } from 'nuqs' +import { ShallowDisplay } from './shallow-display' +import { shallowSearchParams } from './shallow.defs' + +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 ( + <> + + + + ) +} 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/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", 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/lib/patch-history.test.ts b/packages/nuqs/src/adapters/lib/patch-history.test.ts new file mode 100644 index 000000000..5eb88daa4 --- /dev/null +++ b/packages/nuqs/src/adapters/lib/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/lib/patch-history.ts b/packages/nuqs/src/adapters/lib/patch-history.ts new file mode 100644 index 000000000..d3404a1a3 --- /dev/null +++ b/packages/nuqs/src/adapters/lib/patch-history.ts @@ -0,0 +1,87 @@ +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 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 +) { + 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 { + emitter.emit('update', getSearchParams(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/lib/react-router.ts b/packages/nuqs/src/adapters/lib/react-router.ts new file mode 100644 index 000000000..2a5a937a9 --- /dev/null +++ b/packages/nuqs/src/adapters/lib/react-router.ts @@ -0,0 +1,122 @@ +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 away 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, {}] + +// -- + +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 + } + } + 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/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..ed41f1588 100644 --- a/packages/nuqs/src/adapters/react-router.ts +++ b/packages/nuqs/src/adapters/react-router.ts @@ -1,29 +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` - -import { useNavigate, useSearchParams } from 'react-router-dom' -import { renderQueryString } from '../url-encoding' -import type { AdapterOptions } from './defs' -import { createAdapterProvider } from './internal.context' - -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) +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' diff --git a/packages/nuqs/src/adapters/react-router/v6.ts b/packages/nuqs/src/adapters/react-router/v6.ts index 2102008b2..7acc23d39 100644 --- a/packages/nuqs/src/adapters/react-router/v6.ts +++ b/packages/nuqs/src/adapters/react-router/v6.ts @@ -1,27 +1,17 @@ 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 { createReactRouterBasedAdapter } from '../lib/react-router' -function useNuqsReactRouterV6Adapter() { - 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 - } - ) - } - return { - searchParams, - updateUrl - } -} +const { + enableHistorySync, + useNuqsReactRouterBasedAdapter: useNuqsReactRouterV6Adapter, + useOptimisticSearchParams +} = createReactRouterBasedAdapter( + 'react-router-v6', + useNavigate, + useSearchParams +) + +export { enableHistorySync, useOptimisticSearchParams } export const NuqsAdapter = createAdapterProvider(useNuqsReactRouterV6Adapter) diff --git a/packages/nuqs/src/adapters/react-router/v7.ts b/packages/nuqs/src/adapters/react-router/v7.ts index 934b7db8e..1c46bb41c 100644 --- a/packages/nuqs/src/adapters/react-router/v7.ts +++ b/packages/nuqs/src/adapters/react-router/v7.ts @@ -1,27 +1,17 @@ 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 { createReactRouterBasedAdapter } from '../lib/react-router' -function useNuqsReactRouterV7Adapter() { - 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 - } - ) - } - return { - searchParams, - updateUrl - } -} +const { + enableHistorySync, + useNuqsReactRouterBasedAdapter: useNuqsReactRouterV7Adapter, + useOptimisticSearchParams +} = createReactRouterBasedAdapter( + 'react-router-v7', + useNavigate, + useSearchParams +) + +export { enableHistorySync, useOptimisticSearchParams } export const NuqsAdapter = createAdapterProvider(useNuqsReactRouterV7Adapter) diff --git a/packages/nuqs/src/adapters/react.ts b/packages/nuqs/src/adapters/react.ts index b35818eba..33351b490 100644 --- a/packages/nuqs/src/adapters/react.ts +++ b/packages/nuqs/src/adapters/react.ts @@ -1,10 +1,11 @@ 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' +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') +} diff --git a/packages/nuqs/src/adapters/remix.ts b/packages/nuqs/src/adapters/remix.ts index 4598c3417..db7358860 100644 --- a/packages/nuqs/src/adapters/remix.ts +++ b/packages/nuqs/src/adapters/remix.ts @@ -1,27 +1,13 @@ import { useNavigate, useSearchParams } from '@remix-run/react' -import { renderQueryString } from '../url-encoding' -import type { AdapterOptions } from './defs' -import { createAdapterProvider } from './internal.context' +import { createAdapterProvider } from './lib/context' +import { createReactRouterBasedAdapter } from './lib/react-router' -function useNuqsRemixAdapter() { - 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 - } - ) - } - return { - searchParams, - updateUrl - } -} +const { + enableHistorySync, + useNuqsReactRouterBasedAdapter: useNuqsRemixAdapter, + useOptimisticSearchParams +} = createReactRouterBasedAdapter('remix', useNavigate, useSearchParams) + +export { enableHistorySync, useOptimisticSearchParams } export const NuqsAdapter = createAdapterProvider(useNuqsRemixAdapter) 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/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.", diff --git a/packages/nuqs/src/update-queue.ts b/packages/nuqs/src/update-queue.ts index 83684a6a8..5971362a3 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' @@ -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< 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' 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