Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat: Shallow routing for everyone #811

Merged
merged 23 commits into from
Dec 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
b0841e6
feat: Add shallow routing support for Remix
franky47 Nov 20, 2024
d762812
feat: Bring back history patching
franky47 Dec 7, 2024
12c3e0d
fix: Pick up popstate events (for history: push & back button)
franky47 Dec 16, 2024
6dcfc10
fix: History patching
franky47 Dec 16, 2024
8e471cb
test: Add shallow e2e test for Next.js
franky47 Dec 16, 2024
1136dae
feat: Add shallow support for Remix
franky47 Dec 16, 2024
7597226
chore: Address feedback
franky47 Dec 17, 2024
370a2c5
chore: Stream logs
franky47 Dec 17, 2024
cdc42fa
chore: Move adapters internal code
franky47 Dec 17, 2024
94fc9b6
test: Add shallow support for RRv7
franky47 Dec 17, 2024
0f22bf1
test: Add shallow routing for RRv6
franky47 Dec 17, 2024
5a77786
chore: Disable node:test autoimport suggestions
franky47 Dec 17, 2024
8a2f3cd
test: Add shallow test for React (no-op on shallow: false)
franky47 Dec 17, 2024
555eec4
ref: Refactor react-router-based adapters into a shared implementation
franky47 Dec 17, 2024
aaa54b0
chore: Remove default getSearchParamsSnapshot
franky47 Dec 17, 2024
578890b
doc: Distinguish RRv6 & RRv7 in docs
franky47 Dec 17, 2024
91b823b
doc: Deprecate the re-exported imports
franky47 Dec 17, 2024
2ce1ec8
feat: History API sync for React
franky47 Dec 17, 2024
602dbbd
feat: Enable History sync for React Routers
franky47 Dec 17, 2024
e51a41d
doc: Shallow docs
franky47 Dec 17, 2024
7e6e6de
test: Fail fast when running locally
franky47 Dec 17, 2024
064d6f6
chore: Don't slowdown CI too much though
franky47 Dec 17, 2024
acbdf28
doc: Point to useOptimisticSearchParams & enableHistorySync
franky47 Dec 17, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading