Skip to content

Commit

Permalink
feat: Shallow routing for everyone (#811)
Browse files Browse the repository at this point in the history
  • Loading branch information
franky47 authored Dec 17, 2024
1 parent 277ffca commit abbbb56
Show file tree
Hide file tree
Showing 65 changed files with 935 additions and 164 deletions.
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,8 @@
"label": "Internal",
"query": "repo:47ng/nuqs is:issue label:internal"
}
],
"typescript.preferences.autoImportSpecifierExcludeRegexes": [
"^node:test$" // We use Vitest
]
}
29 changes: 26 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,13 +119,13 @@ export default function App() {

</details>

<details><summary><img style="width:1em;height:1em;" src="https://reactrouter.com/_brand/React%20Router%20Brand%20Assets/React%20Router%20Logo/Dark.svg" /> React Router
<details><summary><img style="width:1em;height:1em;" src="https://reactrouter.com/_brand/React%20Router%20Brand%20Assets/React%20Router%20Logo/Dark.svg" /> React Router v6
</summary>

> 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'

Expand All @@ -147,6 +147,29 @@ export function ReactRouter() {

</details>

<details><summary><img style="width:1em;height:1em;" src="https://reactrouter.com/_brand/React%20Router%20Brand%20Assets/React%20Router%20Logo/Dark.svg" /> React Router v7
</summary>

> 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 (
<NuqsAdapter>
<Outlet />
</NuqsAdapter>
)
}
```

</details>

## Usage

```tsx
Expand Down
3 changes: 2 additions & 1 deletion errors/NUQS-404.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
33 changes: 29 additions & 4 deletions packages/docs/content/docs/adapters.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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'

Expand All @@ -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 (
<NuqsAdapter>
<Outlet />
</NuqsAdapter>
)
}
```

<Callout type="warn" title="Deprecation notice">
The generic import `nuqs/adapters/react-router` (pointing to v6)
is deprecated and will be removed in [email protected].

Please pin your imports to the specific version,
eg: `nuqs/adapters/react-router/v6` or `nuqs/adapters/react-router/v7`.
</Callout>

## Testing

Expand Down
3 changes: 2 additions & 1 deletion packages/docs/content/docs/installation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`

<Callout>
For older versions of Next.js, you may use `nuqs@^1` (documentation in the README).
Expand Down
57 changes: 54 additions & 3 deletions packages/docs/content/docs/options.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <div>{searchParams.get('foo')}</div>
}
```

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

Expand Down
25 changes: 25 additions & 0 deletions packages/e2e/next/cypress/e2e/shared/shallow.cy.ts
Original file line number Diff line number Diff line change
@@ -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'
})
Original file line number Diff line number Diff line change
@@ -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<SearchParams>
}

const cache = createSearchParamsCache(
{
state: parseAsString
},
{
urlKeys: {
state: 'test'
}
}
)

export default async function Page({ searchParams }: PageProps) {
await cache.parse(searchParams)
return (
<>
<Suspense>
<ShallowUseQueryState />
</Suspense>
<ShallowDisplay environment="server" state={cache.get('state')} />
</>
)
}
Original file line number Diff line number Diff line change
@@ -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<SearchParams>
}

const cache = createSearchParamsCache(
{
state: parseAsString
},
{
urlKeys: {
state: 'test'
}
}
)

export default async function Page({ searchParams }: PageProps) {
await cache.parse(searchParams)
return (
<>
<Suspense>
<ShallowUseQueryStates />
</Suspense>
<ShallowDisplay environment="server" state={cache.get('state')} />
</>
)
}
26 changes: 26 additions & 0 deletions packages/e2e/next/src/pages/pages/shallow/useQueryState.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<ShallowUseQueryState />
<ShallowDisplay environment="server" state={serverState} />
</>
)
}

export function getServerSideProps(
ctx: GetServerSidePropsContext
): GetServerSidePropsResult<Props> {
return {
props: {
serverState: ctx.query.test as string | null
}
}
}
26 changes: 26 additions & 0 deletions packages/e2e/next/src/pages/pages/shallow/useQueryStates.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<ShallowUseQueryStates />
<ShallowDisplay environment="server" state={serverState} />
</>
)
}

export function getServerSideProps(
ctx: GetServerSidePropsContext
): GetServerSidePropsResult<Props> {
return {
props: {
serverState: ctx.query.test as string | null
}
}
}
6 changes: 2 additions & 4 deletions packages/e2e/react-router/v6/cypress/e2e/shared/routing.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
})
11 changes: 11 additions & 0 deletions packages/e2e/react-router/v6/cypress/e2e/shared/shallow.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { testShallow } from 'e2e-shared/specs/shallow.cy'

testShallow({
path: '/shallow/useQueryState',
hook: 'useQueryState'
})

testShallow({
path: '/shallow/useQueryStates',
hook: 'useQueryStates'
})
Loading

0 comments on commit abbbb56

Please sign in to comment.