diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index fcf491a3..f6b8424a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -6,30 +6,38 @@ labels: bug assignees: '' --- - + ## Context What's your version of `nuqs`? ``` --> Paste result from `cat package.json | grep -e nuqs -e next-usequerystate` here +-> Paste result from `cat package.json | grep -e nuqs` here ``` -Next.js information (obtained by running `next info`): +What framework are you using? -``` --> Paste result from `next info` here -``` + -Are you using: +- ✅/❌ Next.js (app router) +- ✅/❌ Next.js (pages router) +- ✅/❌ React SPA (no router) +- ✅/❌ Remix +- ✅/❌ React Router +- ✅/❌ Other (please specify) - +Which version of your framework are you using? -- ✅/❌ The app router -- ✅/❌ The pages router -- ✅/❌ The `basePath` option in your Next.js config -- ✅/❌ The experimental `windowHistorySupport` flag in your Next.js config + + +``` +-> Paste the relevant framework versions from your package.json here +``` ## Description diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 11155b06..233d4ca1 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -18,7 +18,7 @@ jobs: # Watch out! When changing the job name, # update the required checks in GitHub # branch protection settings for `next`. - name: CI (${{ matrix.next-version }}${{ matrix.base-path && ' basePath' || ''}}${{ matrix.window-history-support && ' WHS' || ''}}) + name: CI (${{ matrix.next-version }}${{ matrix.base-path && ' basePath' || ''}}) runs-on: ubuntu-latest strategy: fail-fast: false @@ -27,22 +27,17 @@ jobs: # update the required checks in GitHub # branch protection settings for `next`. base-path: [false, '/base'] - window-history-support: [false] next-version: - - '13.4' - - '13.5' - - '14.0.1' - # 14.0.2 is not compatible due to a prefetch issue - # 14.0.3 requires the WHS flag (see below) - - '14.0.4' - - latest # Current latest is 14.1.0 - - local # Use the local workspace version (don't override) - include: - - next-version: '14.0.3' - window-history-support: true - # 14.0.4 doesn't require the WHS flag, but supports it - - next-version: '14.0.4' - window-history-support: true + # Only keep versions where there were relevant changes in the app router core, + # and the previous one to use as a baseline. + - '14.2.0' + - '14.2.3' # before vercel/next.js#66755 + - '14.2.4' # after vercel/next.js#66755 + - '14.2.7' # before vercel/next.js#69509 + - '14.2.8' # after vercel/next.js#69509 + - '15.0.0' + - latest + - local steps: - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 @@ -60,7 +55,6 @@ jobs: run: pnpm run test ${{ github.ref_name == 'ci' && '--force' || '' }} env: BASE_PATH: ${{ matrix.base-path && matrix.base-path || '/' }} - WINDOW_HISTORY_SUPPORT: ${{ matrix.window-history-support }} TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TEAM: ${{ secrets.TURBO_TEAM }} E2E_NO_CACHE_ON_RERUN: ${{ github.run_attempt }} @@ -69,13 +63,13 @@ jobs: if: failure() with: path: packages/e2e/cypress/screenshots - name: ci-${{ matrix.next-version }}${{ matrix.base-path && '-basePath' || ''}}${{ matrix.window-history-support && '-whs' || ''}} + name: ci-${{ matrix.next-version }}${{ matrix.base-path && '-basePath' || ''}} - uses: 47ng/actions-slack-notify@main name: Notify on Slack if: always() with: status: ${{ job.status }} - jobName: next@${{ matrix.next-version }}${{ matrix.base-path && ' basePath' || ''}}${{ matrix.window-history-support && ' WHS' || ''}} + jobName: next@${{ matrix.next-version }}${{ matrix.base-path && ' basePath' || ''}} env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} @@ -92,7 +86,7 @@ jobs: - name: Install dependencies run: pnpm install --ignore-scripts --frozen-lockfile - name: Check monorepo with Sherif - run: pnpm dlx sherif@1.0.0 --no-install + run: pnpm run lint:sherif - name: Check source code formatting run: | set +e # Allow Prettier to fail, but capture the error code @@ -132,11 +126,5 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - - name: Mirror to next-usequerystate - run: ./scripts/mirror.sh - working-directory: packages/nuqs - continue-on-error: true - env: - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Invalidate ISR cache for NPM in the docs run: curl -s "https://nuqs.47ng.com/api/isr?tag=npm&token=${{ secrets.ISR_TOKEN }}" diff --git a/.github/workflows/test-against-nextjs-release.yml b/.github/workflows/test-against-nextjs-release.yml index a353f0db..cba06257 100644 --- a/.github/workflows/test-against-nextjs-release.yml +++ b/.github/workflows/test-against-nextjs-release.yml @@ -14,12 +14,13 @@ env: jobs: test_against_nextjs_release: - name: CI (${{ inputs.version }}${{ matrix.base-path && ' basePath' || ''}}) + name: CI (${{ inputs.version }}${{ matrix.base-path && ' basePath' || ''}}${{ matrix.react-compiler && ' ⚛️⚡️' || ''}}) runs-on: ubuntu-latest strategy: fail-fast: false matrix: base-path: [false, '/base'] + react-compiler: [true, false] steps: - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 - uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2 @@ -35,6 +36,7 @@ jobs: run: pnpm run test env: BASE_PATH: ${{ matrix.base-path && matrix.base-path || '/' }} + REACT_COMPILER: ${{ matrix.react-compiler }} TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TEAM: ${{ secrets.TURBO_TEAM }} E2E_NO_CACHE_ON_RERUN: ${{ github.run_attempt }} @@ -43,13 +45,13 @@ jobs: if: failure() with: path: packages/e2e/cypress/screenshots - name: ci-${{ inputs.version }}${{ matrix.base-path && '-basePath' || ''}} + name: ci-${{ inputs.version }}${{ matrix.base-path && '-basePath' || ''}}${{ matrix.react-compiler && '-react-compiler' || ''}} - uses: 47ng/actions-slack-notify@main name: Notify on Slack if: always() with: status: ${{ job.status }} - jobName: next@${{ inputs.version }}${{ matrix.base-path && ' basePath' || ''}} + jobName: next@${{ inputs.version }}${{ matrix.base-path && ' basePath' || ''}}${{ matrix.react-compiler && ' ⚛️⚡️' || ''}} env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} diff --git a/.gitignore b/.gitignore index 7f7e15bc..918e298f 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ package-lock.json .next/ .turbo/ .vercel +.tsbuildinfo diff --git a/README.md b/README.md index 12c5709c..17b70f6d 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,26 @@ -# useQueryState for Next.js +# nuqs [![NPM](https://img.shields.io/npm/v/nuqs?color=red)](https://www.npmjs.com/package/nuqs) [![MIT License](https://img.shields.io/github/license/47ng/nuqs.svg?color=blue)](https://github.com/47ng/nuqs/blob/next/LICENSE) [![Continuous Integration](https://github.com/47ng/nuqs/workflows/Continuous%20Integration/badge.svg?branch=next)](https://github.com/47ng/nuqs/actions) [![Depfu](https://badges.depfu.com/badges/acad53fa2b09b1e435a19d6d18f29af4/count.svg)](https://depfu.com/github/47ng/nuqs?project_id=22104) - - -useQueryState hook for Next.js - Like React.useState, but stored in the URL query string +Type-safe search params state manager for React frameworks. Like `useState`, but stored in the URL query string. ## Features -- 🔀 Supports both the `app` and `pages` routers +- 🔀 **new:** Supports Next.js (`app` and `pages` routers), plain React (SPA), Remix, React Router, and custom routers via [adapters](#adapters) - 🧘‍♀️ Simple: the URL is the source of truth - 🕰 Replace history or [append](#history) to use the Back button to navigate state updates -- ⚡️ Built-in [parsers](#parsing) for common state types (integer, float, boolean, Date, and more) +- ⚡️ Built-in [parsers](#parsing) for common state types (integer, float, boolean, Date, and more). Create your own parsers for custom types & pretty URLs - ♊️ Related querystrings with [`useQueryStates`](#usequerystates) - 📡 [Shallow mode](#shallow) by default for URL query updates, opt-in to notify server components -- 🗃 _**new:**_ [Server cache](#accessing-searchparams-in-server-components) for type-safe searchParams access in nested server components -- ⌛️ _**new:**_ Support for [`useTransition`](#transitions) to get loading states on server updates +- 🗃 [Server cache](#accessing-searchparams-in-server-components) for type-safe searchParams access in nested server components +- ⌛️ Support for [`useTransition`](#transitions) to get loading states on server updates + +## Documentation + +Read the complete documentation at [nuqs.47ng.com](https://nuqs.47ng.com). ## Installation @@ -34,25 +36,121 @@ yarn add nuqs npm install nuqs ``` -> Note: the package is moving to a new name: `nuqs` :tada: -> -> The 1.x versions will also be available under `next-usequerystate`, -> but 2.x onwards will only be published under `nuqs`. +## Adapters + +You will need to wrap your React component tree with an adapter for your framework. _(expand the appropriate section below)_ + +
▲ Next.js (app router) + +> Supported Next.js versions: `>=14.2.0`. For older versions, install `nuqs@^1` (which doesn't need this adapter code). + +```tsx +// src/app/layout.tsx +import { NuqsAdapter } from 'nuqs/adapters/next/app' +import { type ReactNode } from 'react' + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ) +} +``` + +
+ +
▲ Next.js (pages router) + +> Supported Next.js versions: `>=14.2.0`. For older versions, install `nuqs@^1` (which doesn't need this adapter code). + +```tsx +// src/pages/_app.tsx +import type { AppProps } from 'next/app' +import { NuqsAdapter } from 'nuqs/adapters/next/pages' + +export default function MyApp({ Component, pageProps }: AppProps) { + return ( + + + + ) +} +``` + +
+ +
⚛️ Plain React (SPA) + +Example: via Vite or create-react-app. + +```tsx +import { NuqsAdapter } from 'nuqs/adapters/react' + +createRoot(document.getElementById('root')!).render( + + + +) +``` + +
+ +
💿 Remix + +> Supported Remix versions: `@remix-run/react@>=2` + +```tsx +// app/root.tsx +import { NuqsAdapter } from 'nuqs/adapters/remix' + +// ... + +export default function App() { + return ( + + + + ) +} +``` + +
+ +
React Router + + +> Supported React Router versions: `react-router-dom@>=6` -### Which version should I use? +```tsx +import { NuqsAdapter } from 'nuqs/adapters/react-router' +import { createBrowserRouter, RouterProvider } from 'react-router-dom' +import App from './App' + +const router = createBrowserRouter([ + { + path: '/', + element: + } +]) + +export function ReactRouter() { + return ( + + + + ) +} +``` -| Next.js version range | Supported `nuqs` / `next-usequerystate` version | -| --------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------- | -| >=14.0.4 | `nuqs@latest` | -| 14.0.3 | `nuqs@latest`, with the `windowHistorySupport` experimental flag, see [#417](https://github.com/47ng/nuqs/issues/417) | -| 14.0.2 | Not compatible, see issue [#388](https://github.com/47ng/nuqs/issues/388) and Next.js PR [#58297](https://github.com/vercel/next.js/pull/58297) | -| >= 13.1 && <= 14.0.1 | `nuqs@latest` | -| < 13.1 | `next-usequerystate@1.7.3` | +
## Usage ```tsx -'use client' // app router: only works in client components +'use client' // Only works in client components import { useQueryState } from 'nuqs' @@ -70,8 +168,6 @@ export default () => { ![](https://raw.githubusercontent.com/47ng/nuqs/next/useQueryState.gif) -## Documentation - `useQueryState` takes one required argument: the key to use in the query string. Like `React.useState`, it returns an array with the value present in the query @@ -280,6 +376,8 @@ setQuery(null, { history: 'replace' }) ### Shallow +> Note: this feature only applies to Next.js + By default, query state updates are done in a _client-first_ manner: there are no network calls to the server. @@ -298,21 +396,6 @@ const [state, setState] = useQueryState('foo', { shallow: false }) setState('bar', { shallow: false }) ``` -### Scroll - -The Next.js router scrolls to the top of the page on navigation updates, -which may not be desirable when updating the query string with local state. - -Query state updates won't scroll to the top of the page by default, but you -can opt-in to this behaviour (which was the default up to 1.8.0): - -```ts -const [state, setState] = useQueryState('foo', { scroll: true }) - -// You can also pass the option on calls to setState: -setState('bar', { scroll: true }) -``` - ### Throttling URL updates Because of browsers rate-limiting the History API, internal updates to the @@ -349,7 +432,7 @@ loading states while the server is re-rendering server components with the updated URL. Pass in the `startTransition` function from `useTransition` to the options -to enable this behaviour _(this will set `shallow: false` automatically for you)_: +to enable this behaviour: ```tsx 'use client' @@ -363,7 +446,10 @@ function ClientComponent({ data }) { const [query, setQuery] = useQueryState( 'query', // 2. Pass the `startTransition` as an option: - parseAsString().withOptions({ startTransition }) + parseAsString().withOptions({ + startTransition, + shallow: false // opt-in to notify the server (Next.js only) + }) ) // 3. `isLoading` will be true while the server is re-rendering // and streaming RSC payloads, when the query is updated via `setQuery`. @@ -717,14 +803,46 @@ inferParserType ## Testing -Currently, the best way to test the behaviour of your components using -`useQueryState(s)` is end-to-end testing, with tools like Playwright or Cypress. +Since nuqs v2, you can use a testing adapter to unit-test components using +`useQueryState` and `useQueryStates` in isolation, without needing to mock +your framework or router. + +Here's an example using Testing Library and Vitest: -Running components that use the Next.js router in isolation requires mocking it, -which is being [worked on](https://github.com/scottrippey/next-router-mock/pull/103) -for the app router. +```tsx +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { NuqsTestingAdapter, type UrlUpdateEvent } from 'nuqs/adapters/testing' +import { describe, expect, it, vi } from 'vitest' +import { CounterButton } from './counter-button' + +it('should increment the count when clicked', async () => { + const user = userEvent.setup() + const onUrlUpdate = vi.fn<[UrlUpdateEvent]>() + render(, { + // Setup the test by passing initial search params / querystring, + // and give it a function to call on URL updates + wrapper: ({ children }) => ( + + {children} + + ) + }) + // Initial state assertions: there's a clickable button displaying the count + const button = screen.getByRole('button') + expect(button).toHaveTextContent('count is 42') + // Act + await user.click(button) + // Assert changes in the state and in the (mocked) URL + expect(button).toHaveTextContent('count is 43') + expect(onUrlUpdate).toHaveBeenCalledOnce() + expect(onUrlUpdate.mock.calls[0][0].queryString).toBe('?count=43') + expect(onUrlUpdate.mock.calls[0][0].searchParams.get('count')).toBe('43') + expect(onUrlUpdate.mock.calls[0][0].options.history).toBe('push') +}) +``` -See issue #259 for more testing-related discussions. +See [#259](https://github.com/47ng/nuqs/issues/259) for more testing-related discussions. ## Debugging @@ -748,13 +866,6 @@ your browser's devtools. Providing debug logs when opening an [issue](https://github.com/47ng/nuqs/issues) is always appreciated. 🙏 -## Caveats - -Because the Next.js **pages router** is not available in an SSR context, this -hook will always return `null` (or the default value if supplied) on SSR/SSG. - -This limitation doesn't apply to the app router. - ### SEO If your page uses query strings for local-only state, you should add a diff --git a/errors/NUQS-404.md b/errors/NUQS-404.md new file mode 100644 index 00000000..e0dc39f3 --- /dev/null +++ b/errors/NUQS-404.md @@ -0,0 +1,28 @@ +# `nuqs` requires an adapter to work with your framework + +## Probable cause + +You haven't wrapped the components calling `useQueryState(s)` with +an adapter. + +[Adapters](https://nuqs.47ng.com/docs/adapters) are based on React Context, +and provide nuqs hooks with the interfaces to work with your framework: +reacting to URL changes, and calling your router when you update your state. + +## Possible solutions + +Follow the setup instructions to import and wrap your application +using a suitable adapter: + +- [Next.js (app router)](https://nuqs.47ng.com/docs/adapters#nextjs-app-router) +- [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) + +### Test adapter + +If you encounter this error outside of the browser, like in a test +runner (eg: Vitest or Jest), you may use the [testing adapter](https://nuqs.47ng.com/docs/testing) +from `nuqs/adapters/testing` to mock the initial search params and access +setup/assertion testing facilities. diff --git a/package.json b/package.json index 9735ce44..87338224 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,10 @@ "dev": "turbo run dev", "build": "turbo run build", "test": "turbo run test", - "prepare": "husky" + "prepare": "husky", + "lint": "pnpm run -w --parallel --stream '/^lint:/'", + "lint:prettier": "prettier --check ./packages/nuqs/src/**/*.ts", + "lint:sherif": "sherif" }, "devDependencies": { "@commitlint/config-conventional": "^19.5.0", @@ -24,6 +27,7 @@ "husky": "^9.1.6", "prettier": "3.3.3", "semantic-release": "^24.1.2", + "sherif": "^1.0.0", "turbo": "^2.1.3", "typescript": "^5.6.3" }, @@ -38,7 +42,8 @@ }, "husky": { "hooks": { - "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" + "commit-msg": "commitlint -E HUSKY_GIT_PARAMS", + "pre-push": "pnpm run lint" } }, "commitlint": { diff --git a/packages/adapters/react/.gitignore b/packages/adapters/react/.gitignore new file mode 100644 index 00000000..a547bf36 --- /dev/null +++ b/packages/adapters/react/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/packages/adapters/react/README.md b/packages/adapters/react/README.md new file mode 100644 index 00000000..74872fd4 --- /dev/null +++ b/packages/adapters/react/README.md @@ -0,0 +1,50 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: + +- Configure the top-level `parserOptions` property like this: + +```js +export default tseslint.config({ + languageOptions: { + // other options... + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + }, +}) +``` + +- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked` +- Optionally add `...tseslint.configs.stylisticTypeChecked` +- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config: + +```js +// eslint.config.js +import react from 'eslint-plugin-react' + +export default tseslint.config({ + // Set the react version + settings: { react: { version: '18.3' } }, + plugins: { + // Add the react plugin + react, + }, + rules: { + // other rules... + // Enable its recommended rules + ...react.configs.recommended.rules, + ...react.configs['jsx-runtime'].rules, + }, +}) +``` diff --git a/packages/adapters/react/index.html b/packages/adapters/react/index.html new file mode 100644 index 00000000..f2f595d3 --- /dev/null +++ b/packages/adapters/react/index.html @@ -0,0 +1,14 @@ + + + + + + + Vite + React + TS + + +
+
+ + + diff --git a/packages/adapters/react/package.json b/packages/adapters/react/package.json new file mode 100644 index 00000000..39fdf681 --- /dev/null +++ b/packages/adapters/react/package.json @@ -0,0 +1,33 @@ +{ + "name": "adapters-react", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite --port 4000", + "build": "tsc -b && vite build", + "preview": "vite preview", + "test": "vitest" + }, + "dependencies": { + "nuqs": "workspace:*", + "react": "rc", + "react-dom": "rc", + "react-router-dom": "^6.26.2" + }, + "devDependencies": { + "@testing-library/dom": "^10.1.0", + "@testing-library/jest-dom": "^6.4.5", + "@testing-library/react": "^15.0.7", + "@testing-library/user-event": "^14.5.2", + "@types/node": "^20.16.3", + "@types/react": "^18.3.7", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "globals": "^15.9.0", + "jsdom": "^24.1.0", + "typescript": "^5.5.3", + "vite": "^5.4.1", + "vitest": "^1.6.0" + } +} diff --git a/packages/adapters/react/src/App.tsx b/packages/adapters/react/src/App.tsx new file mode 100644 index 00000000..e3e179d5 --- /dev/null +++ b/packages/adapters/react/src/App.tsx @@ -0,0 +1,18 @@ +import { CounterButton } from './components/counter-button' +import { SearchInput } from './components/search-input' + +type AppProps = { + router: string +} + +export default function App({ router }: AppProps) { + return ( + <> +

{router} + nuqs

+
+ + +
+ + ) +} diff --git a/packages/adapters/react/src/components/counter-button.test.tsx b/packages/adapters/react/src/components/counter-button.test.tsx new file mode 100644 index 00000000..f95a2487 --- /dev/null +++ b/packages/adapters/react/src/components/counter-button.test.tsx @@ -0,0 +1,36 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { NuqsTestingAdapter, type UrlUpdateEvent } from 'nuqs/adapters/testing' +import { describe, expect, it, vi } from 'vitest' +import { CounterButton } from './counter-button' + +describe('CounterButton', () => { + it('should render the button with state loaded from the URL', () => { + render(, { + wrapper: ({ children }) => ( + + {children} + + ) + }) + expect(screen.getByRole('button')).toHaveTextContent('count is 42') + }) + it('should increment the count when clicked', async () => { + const user = userEvent.setup() + const onUrlUpdate = vi.fn<[UrlUpdateEvent]>() + render(, { + wrapper: ({ children }) => ( + + {children} + + ) + }) + const button = screen.getByRole('button') + await user.click(button) + expect(button).toHaveTextContent('count is 43') + expect(onUrlUpdate).toHaveBeenCalledOnce() + expect(onUrlUpdate.mock.calls[0][0].queryString).toBe('?count=43') + expect(onUrlUpdate.mock.calls[0][0].searchParams.get('count')).toBe('43') + expect(onUrlUpdate.mock.calls[0][0].options.history).toBe('push') + }) +}) diff --git a/packages/adapters/react/src/components/counter-button.tsx b/packages/adapters/react/src/components/counter-button.tsx new file mode 100644 index 00000000..e4a53c3b --- /dev/null +++ b/packages/adapters/react/src/components/counter-button.tsx @@ -0,0 +1,9 @@ +import { parseAsInteger, useQueryState } from 'nuqs' + +export function CounterButton() { + const [count, setCount] = useQueryState( + 'count', + parseAsInteger.withDefault(0).withOptions({ history: 'push' }) + ) + return +} diff --git a/packages/adapters/react/src/components/search-input.test.tsx b/packages/adapters/react/src/components/search-input.test.tsx new file mode 100644 index 00000000..db9a450e --- /dev/null +++ b/packages/adapters/react/src/components/search-input.test.tsx @@ -0,0 +1,45 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { NuqsTestingAdapter, type UrlUpdateEvent } from 'nuqs/adapters/testing' +import { describe, expect, it, vi } from 'vitest' +import { SearchInput } from './search-input' + +describe('SearchInput', () => { + it('should render the input with state loaded from the URL', () => { + render(, { + wrapper: ({ children }) => ( + + {children} + + ) + }) + const input = screen.getByRole('search') + expect(input).toHaveValue('nuqs') + }) + it('should follow the user typing text', async () => { + const user = userEvent.setup() + const onUrlUpdate = vi.fn<[UrlUpdateEvent]>() + render(, { + wrapper: ({ children }) => ( + + {children} + + ) + }) + const expectedState = 'Hello, world!' + const expectedParam = 'Hello,+world!' + const searchInput = screen.getByRole('search') + await user.type(searchInput, expectedState) + expect(searchInput).toHaveValue(expectedState) + expect(onUrlUpdate).toHaveBeenCalledTimes(expectedParam.length) + for (let i = 0; i < expectedParam.length; i++) { + expect(onUrlUpdate.mock.calls[i][0].queryString).toBe( + `?search=${expectedParam.slice(0, i + 1)}` + ) + } + }) +}) diff --git a/packages/adapters/react/src/components/search-input.tsx b/packages/adapters/react/src/components/search-input.tsx new file mode 100644 index 00000000..b6baf42c --- /dev/null +++ b/packages/adapters/react/src/components/search-input.tsx @@ -0,0 +1,15 @@ +import { parseAsString, useQueryStates } from 'nuqs' + +export function SearchInput() { + const [{ search }, setSearch] = useQueryStates({ + search: parseAsString.withDefault('').withOptions({ clearOnDefault: true }) + }) + return ( + setSearch({ search: e.target.value })} + /> + ) +} diff --git a/packages/adapters/react/src/main.tsx b/packages/adapters/react/src/main.tsx new file mode 100644 index 00000000..c4d7bdf4 --- /dev/null +++ b/packages/adapters/react/src/main.tsx @@ -0,0 +1,16 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { ReactRouter } from './react-router' +import { VanillaReact } from './vanilla-react' + +createRoot(document.getElementById('root-vanilla')!).render( + + + +) + +createRoot(document.getElementById('root-react-router')!).render( + + + +) diff --git a/packages/adapters/react/src/react-router.tsx b/packages/adapters/react/src/react-router.tsx new file mode 100644 index 00000000..f9edc7b9 --- /dev/null +++ b/packages/adapters/react/src/react-router.tsx @@ -0,0 +1,18 @@ +import { NuqsAdapter } from 'nuqs/adapters/react-router' +import { createBrowserRouter, RouterProvider } from 'react-router-dom' +import App from './App' + +const router = createBrowserRouter([ + { + path: '/', + element: + } +]) + +export function ReactRouter() { + return ( + + + + ) +} diff --git a/packages/adapters/react/src/vanilla-react.tsx b/packages/adapters/react/src/vanilla-react.tsx new file mode 100644 index 00000000..7e607786 --- /dev/null +++ b/packages/adapters/react/src/vanilla-react.tsx @@ -0,0 +1,10 @@ +import { NuqsAdapter } from 'nuqs/adapters/react' +import App from './App' + +export function VanillaReact() { + return ( + + + + ) +} diff --git a/packages/adapters/react/src/vite-env.d.ts b/packages/adapters/react/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/packages/adapters/react/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/adapters/react/tsconfig.app.json b/packages/adapters/react/tsconfig.app.json new file mode 100644 index 00000000..24f9c8af --- /dev/null +++ b/packages/adapters/react/tsconfig.app.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "types": ["node", "@testing-library/jest-dom"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/packages/adapters/react/tsconfig.json b/packages/adapters/react/tsconfig.json new file mode 100644 index 00000000..1ffef600 --- /dev/null +++ b/packages/adapters/react/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/packages/adapters/react/tsconfig.node.json b/packages/adapters/react/tsconfig.node.json new file mode 100644 index 00000000..7aeaa87e --- /dev/null +++ b/packages/adapters/react/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "types": ["node", "@testing-library/jest-dom"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/packages/adapters/react/vite.config.ts b/packages/adapters/react/vite.config.ts new file mode 100644 index 00000000..570ede4e --- /dev/null +++ b/packages/adapters/react/vite.config.ts @@ -0,0 +1,17 @@ +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite' + +// https://vitejs.dev/config/ +export default defineConfig(() => ({ + plugins: [react()], + // Vitest configuration + test: { + globals: true, + environment: 'jsdom', + setupFiles: ['vitest.setup.ts'], + include: ['**/*.test.?(c|m)[jt]s?(x)'], + env: { + IS_REACT_ACT_ENVIRONMENT: 'true' + } + } +})) diff --git a/packages/adapters/react/vitest.setup.ts b/packages/adapters/react/vitest.setup.ts new file mode 100644 index 00000000..72fa6db9 --- /dev/null +++ b/packages/adapters/react/vitest.setup.ts @@ -0,0 +1,8 @@ +import * as matchers from '@testing-library/jest-dom/matchers' +import { cleanup } from '@testing-library/react' +import { afterEach, expect } from 'vitest' + +expect.extend(matchers) + +// https://testing-library.com/docs/react-testing-library/api/#cleanup +afterEach(cleanup) diff --git a/packages/adapters/remix/.eslintrc.cjs b/packages/adapters/remix/.eslintrc.cjs new file mode 100644 index 00000000..4f6f59ee --- /dev/null +++ b/packages/adapters/remix/.eslintrc.cjs @@ -0,0 +1,84 @@ +/** + * This is intended to be a basic starting point for linting in your app. + * It relies on recommended configs out of the box for simplicity, but you can + * and should modify this configuration to best suit your team's needs. + */ + +/** @type {import('eslint').Linter.Config} */ +module.exports = { + root: true, + parserOptions: { + ecmaVersion: "latest", + sourceType: "module", + ecmaFeatures: { + jsx: true, + }, + }, + env: { + browser: true, + commonjs: true, + es6: true, + }, + ignorePatterns: ["!**/.server", "!**/.client"], + + // Base config + extends: ["eslint:recommended"], + + overrides: [ + // React + { + files: ["**/*.{js,jsx,ts,tsx}"], + plugins: ["react", "jsx-a11y"], + extends: [ + "plugin:react/recommended", + "plugin:react/jsx-runtime", + "plugin:react-hooks/recommended", + "plugin:jsx-a11y/recommended", + ], + settings: { + react: { + version: "detect", + }, + formComponents: ["Form"], + linkComponents: [ + { name: "Link", linkAttribute: "to" }, + { name: "NavLink", linkAttribute: "to" }, + ], + "import/resolver": { + typescript: {}, + }, + }, + }, + + // Typescript + { + files: ["**/*.{ts,tsx}"], + plugins: ["@typescript-eslint", "import"], + parser: "@typescript-eslint/parser", + settings: { + "import/internal-regex": "^~/", + "import/resolver": { + node: { + extensions: [".ts", ".tsx"], + }, + typescript: { + alwaysTryTypes: true, + }, + }, + }, + extends: [ + "plugin:@typescript-eslint/recommended", + "plugin:import/recommended", + "plugin:import/typescript", + ], + }, + + // Node + { + files: [".eslintrc.cjs"], + env: { + node: true, + }, + }, + ], +}; diff --git a/packages/adapters/remix/.gitignore b/packages/adapters/remix/.gitignore new file mode 100644 index 00000000..80ec311f --- /dev/null +++ b/packages/adapters/remix/.gitignore @@ -0,0 +1,5 @@ +node_modules + +/.cache +/build +.env diff --git a/packages/adapters/remix/README.md b/packages/adapters/remix/README.md new file mode 100644 index 00000000..6c4d2168 --- /dev/null +++ b/packages/adapters/remix/README.md @@ -0,0 +1,40 @@ +# Welcome to Remix! + +- 📖 [Remix docs](https://remix.run/docs) + +## Development + +Run the dev server: + +```shellscript +npm run dev +``` + +## Deployment + +First, build your app for production: + +```sh +npm run build +``` + +Then run the app in production mode: + +```sh +npm start +``` + +Now you'll need to pick a host to deploy it to. + +### DIY + +If you're familiar with deploying Node applications, the built-in Remix app server is production-ready. + +Make sure to deploy the output of `npm run build` + +- `build/server` +- `build/client` + +## Styling + +This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever css framework you prefer. See the [Vite docs on css](https://vitejs.dev/guide/features.html#css) for more information. diff --git a/packages/adapters/remix/app/components/counter-button.tsx b/packages/adapters/remix/app/components/counter-button.tsx new file mode 100644 index 00000000..e4a53c3b --- /dev/null +++ b/packages/adapters/remix/app/components/counter-button.tsx @@ -0,0 +1,9 @@ +import { parseAsInteger, useQueryState } from 'nuqs' + +export function CounterButton() { + const [count, setCount] = useQueryState( + 'count', + parseAsInteger.withDefault(0).withOptions({ history: 'push' }) + ) + return +} diff --git a/packages/adapters/remix/app/components/search-input.tsx b/packages/adapters/remix/app/components/search-input.tsx new file mode 100644 index 00000000..9a8154bf --- /dev/null +++ b/packages/adapters/remix/app/components/search-input.tsx @@ -0,0 +1,14 @@ +import { parseAsString, useQueryStates } from 'nuqs' + +export function SearchInput() { + const [{ search }, setSearch] = useQueryStates({ + search: parseAsString.withDefault('').withOptions({ clearOnDefault: true }) + }) + return ( + setSearch({ search: e.target.value })} + /> + ) +} diff --git a/packages/adapters/remix/app/entry.client.tsx b/packages/adapters/remix/app/entry.client.tsx new file mode 100644 index 00000000..94d5dc0d --- /dev/null +++ b/packages/adapters/remix/app/entry.client.tsx @@ -0,0 +1,18 @@ +/** + * By default, Remix will handle hydrating your app on the client for you. + * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ + * For more information, see https://remix.run/file-conventions/entry.client + */ + +import { RemixBrowser } from "@remix-run/react"; +import { startTransition, StrictMode } from "react"; +import { hydrateRoot } from "react-dom/client"; + +startTransition(() => { + hydrateRoot( + document, + + + + ); +}); diff --git a/packages/adapters/remix/app/entry.server.tsx b/packages/adapters/remix/app/entry.server.tsx new file mode 100644 index 00000000..45db3229 --- /dev/null +++ b/packages/adapters/remix/app/entry.server.tsx @@ -0,0 +1,140 @@ +/** + * By default, Remix will handle generating the HTTP Response for you. + * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ + * For more information, see https://remix.run/file-conventions/entry.server + */ + +import { PassThrough } from "node:stream"; + +import type { AppLoadContext, EntryContext } from "@remix-run/node"; +import { createReadableStreamFromReadable } from "@remix-run/node"; +import { RemixServer } from "@remix-run/react"; +import { isbot } from "isbot"; +import { renderToPipeableStream } from "react-dom/server"; + +const ABORT_DELAY = 5_000; + +export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, + // This is ignored so we can keep it in the template for visibility. Feel + // free to delete this parameter in your app if you're not using it! + // eslint-disable-next-line @typescript-eslint/no-unused-vars + loadContext: AppLoadContext +) { + return isbot(request.headers.get("user-agent") || "") + ? handleBotRequest( + request, + responseStatusCode, + responseHeaders, + remixContext + ) + : handleBrowserRequest( + request, + responseStatusCode, + responseHeaders, + remixContext + ); +} + +function handleBotRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext +) { + return new Promise((resolve, reject) => { + let shellRendered = false; + const { pipe, abort } = renderToPipeableStream( + , + { + onAllReady() { + shellRendered = true; + const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); + + responseHeaders.set("Content-Type", "text/html"); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }) + ); + + pipe(body); + }, + onShellError(error: unknown) { + reject(error); + }, + onError(error: unknown) { + responseStatusCode = 500; + // Log streaming rendering errors from inside the shell. Don't log + // errors encountered during initial shell rendering since they'll + // reject and get logged in handleDocumentRequest. + if (shellRendered) { + console.error(error); + } + }, + } + ); + + setTimeout(abort, ABORT_DELAY); + }); +} + +function handleBrowserRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext +) { + return new Promise((resolve, reject) => { + let shellRendered = false; + const { pipe, abort } = renderToPipeableStream( + , + { + onShellReady() { + shellRendered = true; + const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); + + responseHeaders.set("Content-Type", "text/html"); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }) + ); + + pipe(body); + }, + onShellError(error: unknown) { + reject(error); + }, + onError(error: unknown) { + responseStatusCode = 500; + // Log streaming rendering errors from inside the shell. Don't log + // errors encountered during initial shell rendering since they'll + // reject and get logged in handleDocumentRequest. + if (shellRendered) { + console.error(error); + } + }, + } + ); + + setTimeout(abort, ABORT_DELAY); + }); +} diff --git a/packages/adapters/remix/app/root.tsx b/packages/adapters/remix/app/root.tsx new file mode 100644 index 00000000..01b605ff --- /dev/null +++ b/packages/adapters/remix/app/root.tsx @@ -0,0 +1,49 @@ +import type { LinksFunction } from '@remix-run/node' +import { + Links, + Meta, + Outlet, + Scripts, + ScrollRestoration +} from '@remix-run/react' +import { NuqsAdapter } from 'nuqs/adapters/remix' +import './tailwind.css' + +export const links: LinksFunction = () => [ + { rel: 'preconnect', href: 'https://fonts.googleapis.com' }, + { + rel: 'preconnect', + href: 'https://fonts.gstatic.com', + crossOrigin: 'anonymous' + }, + { + rel: 'stylesheet', + href: 'https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap' + } +] + +export function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + + {children} + + + + + ) +} + +export default function App() { + return ( + + + + ) +} diff --git a/packages/adapters/remix/app/routes/_index.tsx b/packages/adapters/remix/app/routes/_index.tsx new file mode 100644 index 00000000..91949a80 --- /dev/null +++ b/packages/adapters/remix/app/routes/_index.tsx @@ -0,0 +1,38 @@ +import type { MetaFunction } from '@remix-run/node' +import { CounterButton } from '../components/counter-button' +import { SearchInput } from '../components/search-input' + +export const meta: MetaFunction = () => { + return [ + { title: 'New Remix App' }, + { name: 'description', content: 'Welcome to Remix!' } + ] +} + +export default function Index() { + return ( +
+
+
+

+ Welcome to Remix +

+
+ Remix + Remix +
+
+ + +
+
+ ) +} diff --git a/packages/adapters/remix/app/tailwind.css b/packages/adapters/remix/app/tailwind.css new file mode 100644 index 00000000..303fe158 --- /dev/null +++ b/packages/adapters/remix/app/tailwind.css @@ -0,0 +1,12 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +html, +body { + @apply bg-white dark:bg-gray-950; + + @media (prefers-color-scheme: dark) { + color-scheme: dark; + } +} diff --git a/packages/adapters/remix/package.json b/packages/adapters/remix/package.json new file mode 100644 index 00000000..57c42538 --- /dev/null +++ b/packages/adapters/remix/package.json @@ -0,0 +1,44 @@ +{ + "name": "adapters-remix", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "build": "remix vite:build", + "dev": "remix vite:dev --port 4001", + "lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .", + "start": "remix-serve ./build/server/index.js", + "typecheck": "tsc" + }, + "dependencies": { + "@remix-run/node": "^2.12.1", + "@remix-run/react": "^2.12.1", + "@remix-run/serve": "^2.12.1", + "nuqs": "workspace:*", + "isbot": "^4.1.0", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@remix-run/dev": "^2.12.1", + "@types/react": "^18.2.20", + "@types/react-dom": "^18.2.7", + "@typescript-eslint/eslint-plugin": "^6.7.4", + "@typescript-eslint/parser": "^6.7.4", + "autoprefixer": "^10.4.19", + "eslint": "^8.38.0", + "eslint-import-resolver-typescript": "^3.6.1", + "eslint-plugin-import": "^2.28.1", + "eslint-plugin-jsx-a11y": "^6.7.1", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^4.6.0", + "postcss": "^8.4.38", + "tailwindcss": "^3.4.4", + "typescript": "^5.1.6", + "vite": "^5.1.0", + "vite-tsconfig-paths": "^4.2.1" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/packages/adapters/remix/postcss.config.js b/packages/adapters/remix/postcss.config.js new file mode 100644 index 00000000..2aa7205d --- /dev/null +++ b/packages/adapters/remix/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/packages/adapters/remix/public/favicon.ico b/packages/adapters/remix/public/favicon.ico new file mode 100644 index 00000000..8830cf68 Binary files /dev/null and b/packages/adapters/remix/public/favicon.ico differ diff --git a/packages/adapters/remix/public/logo-dark.png b/packages/adapters/remix/public/logo-dark.png new file mode 100644 index 00000000..b24c7aee Binary files /dev/null and b/packages/adapters/remix/public/logo-dark.png differ diff --git a/packages/adapters/remix/public/logo-light.png b/packages/adapters/remix/public/logo-light.png new file mode 100644 index 00000000..4490ae79 Binary files /dev/null and b/packages/adapters/remix/public/logo-light.png differ diff --git a/packages/adapters/remix/tailwind.config.ts b/packages/adapters/remix/tailwind.config.ts new file mode 100644 index 00000000..14d0f00c --- /dev/null +++ b/packages/adapters/remix/tailwind.config.ts @@ -0,0 +1,22 @@ +import type { Config } from "tailwindcss"; + +export default { + content: ["./app/**/{**,.client,.server}/**/*.{js,jsx,ts,tsx}"], + theme: { + extend: { + fontFamily: { + sans: [ + '"Inter"', + "ui-sans-serif", + "system-ui", + "sans-serif", + '"Apple Color Emoji"', + '"Segoe UI Emoji"', + '"Segoe UI Symbol"', + '"Noto Color Emoji"', + ], + }, + }, + }, + plugins: [], +} satisfies Config; diff --git a/packages/adapters/remix/tsconfig.json b/packages/adapters/remix/tsconfig.json new file mode 100644 index 00000000..9d87dd37 --- /dev/null +++ b/packages/adapters/remix/tsconfig.json @@ -0,0 +1,32 @@ +{ + "include": [ + "**/*.ts", + "**/*.tsx", + "**/.server/**/*.ts", + "**/.server/**/*.tsx", + "**/.client/**/*.ts", + "**/.client/**/*.tsx" + ], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "types": ["@remix-run/node", "vite/client"], + "isolatedModules": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "target": "ES2022", + "strict": true, + "allowJs": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "~/*": ["./app/*"] + }, + + // Vite takes care of building everything, not tsc. + "noEmit": true + } +} diff --git a/packages/adapters/remix/vite.config.ts b/packages/adapters/remix/vite.config.ts new file mode 100644 index 00000000..54066fb7 --- /dev/null +++ b/packages/adapters/remix/vite.config.ts @@ -0,0 +1,16 @@ +import { vitePlugin as remix } from "@remix-run/dev"; +import { defineConfig } from "vite"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig({ + plugins: [ + remix({ + future: { + v3_fetcherPersist: true, + v3_relativeSplatPath: true, + v3_throwAbortReason: true, + }, + }), + tsconfigPaths(), + ], +}); diff --git a/packages/docs/content/blog/nuqs-2.mdx b/packages/docs/content/blog/nuqs-2.mdx new file mode 100644 index 00000000..5a057983 --- /dev/null +++ b/packages/docs/content/blog/nuqs-2.mdx @@ -0,0 +1,162 @@ +--- +title: nuqs 2 +description: Opening up to other React frameworks +author: François Best +date: 2024-10-22 +--- + +nuqs@2.0.0 is available, try it now: + +```bash +pnpm add nuqs@latest +``` + +It's packing exciting features & improvements, including: + +- [Support for other React frameworks](#hello-react): Next.js, React SPA, Remix, React Router, and more to come +- A built-in [testing adapter](#testing) to unit-test your components in isolation +- [Bundle size improvements](#bundle-size-improvements) +- Interactive documentation, with [community parsers](/docs/parsers/community) + +
+ +## Hello, React! 👋 ⚛️ [#hello-react] + +nuqs started as a Next.js-only hook, and v2 brings compatibility for other React frameworks: + +- Next.js 14 & 15 (app & pages routers) +- React SPA +- Remix +- React Router + +No code change is necessary in components that use nuqs hooks, +making them **universal** across all supported frameworks. + +The only new requirement is to wrap your React tree with an +[adapter](/docs/adapters) for your framework. + +Example for a React SPA with Vite: + +```tsx title="src/main.tsx" +// [!code word:NuqsAdapter] +import { NuqsAdapter } from 'nuqs/adapters/react' + +createRoot(document.getElementById('root')!).render( + + + +) +``` + + + The [adapters documentation](/docs/adapters) has examples for all supported frameworks. + + +## Testing + +One of the major pain points with nuqs v1 was testing components that used its hooks. + +Nuqs v2 comes with a built-in [testing adapter](/docs/testing) that mocks URL behaviours, +allowing you to test your components in isolation, outside of any framework runtime. + +You can use it with any unit testing framework that renders React components +(I recommend [Vitest](https://vitest.dev) & [Testing Library](https://testing-library.com/)). + +```tsx title="counter-button.test.tsx" +// [!code word:NuqsTestingAdapter] +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { NuqsTestingAdapter, type UrlUpdateEvent } from 'nuqs/adapters/testing' +import { describe, expect, it, vi } from 'vitest' +import { CounterButton } from './counter-button' + +it('should increment the count when clicked', async () => { + const user = userEvent.setup() + const onUrlUpdate = vi.fn<[UrlUpdateEvent]>() + render(, { + // 1. Setup the test by passing initial search params / querystring: + wrapper: ({ children }) => ( + + {children} + + ) + }) + // 2. Act + const button = screen.getByRole('button') + await user.click(button) + // 3. Assert changes in the state and in the (mocked) URL + expect(button).toHaveTextContent('count is 43') + expect(onUrlUpdate).toHaveBeenCalledOnce() + expect(onUrlUpdate.mock.calls[0][0].queryString).toBe('?count=43') + expect(onUrlUpdate.mock.calls[0][0].searchParams.get('count')).toBe('43') + expect(onUrlUpdate.mock.calls[0][0].options.history).toBe('push') +}) +``` + +The adapter conforms to the **setup** / **act** / **assert** testing strategy, allowing you +to: + +1. Set the initial URL search params +2. Let your test framework perform actions on your component +3. Asserting on how the URL was changed as a result + +## Breaking changes & migration + +The biggest breaking change is the introduction of [adapters](/docs/adapters). +Another one is related to deprecated APIs. + +The `next-usequerystate` package that started this journey is no longer updated. +All updates are now published under the `nuqs` package name. + +The minimum version of Next.js supported is now 14.2.0. It is compatible with +Next.js 15, including the async `searchParams{:ts}` page prop in the [server-side cache](/docs/server-side). + +There are some important behaviour changes, based on feedback from the community: + +- [`clearOnDefault{:ts}`](/docs/options#clear-on-default) is now `true{:ts}` by default +- [`startTransition{:ts}`](/docs/options#transitions) no longer sets `shallow: false{:ts}` +- [`parseAsJson{:ts}`](/docs/parsers/built-in#json) now requires a validation function + + +Read the complete [migration guide](/docs/migrations/v2) to update your applications. + + +## Bundle size improvements + +By moving to **ESM-only**, and dropping hacks needed to support older versions of Next.js, +the bundle size is now **20% smaller** than v1. It's also **side-effects free** and **tree-shakable**. + +## What's next? + +The community and I have a lot of ideas for the future of nuqs, including: + +- A unified, scalable, type-safe routing experience in all supported React frameworks +- Community-contributed parsers & adapters +- New options: debouncing, global defaults override +- Middleware to migrate old URLs to new ones +- Better Zod integration for type-safe & runtime-safe validation + +## Thanks + +I want to thank [sponsors](https://github.com/sponsors/franky47), +[contributors](https://github.com/47ng/nuqs/graphs/contributors) +and people who raised issues and discussions on +[GitHub](https://github.com/47ng/nuqs) and [X/Twitter](https://x.com/nuqs47ng). +You are the growing community that drives this project forward, +and I couldn't be happier with the response. + +### Sponsors + +- [Pontus Abrahamsson](https://x.com/pontusab), founder of [Midday.ai](https://midday.ai) +- [Carl Lindesvard](https://x.com/CarlLindesvard), founder of [OpenPanel](https://openpanel.dev) +- [Robin Wieruch](https://x.com/rwieruch), author of [The Road to Next](https://www.road-to-next.com/) +- [Yoann Fleury](https://x.com/YoannFleuryDev) +- [Sunghyun Cho](https://github.com/anaclumos) +- [Jalol](https://github.com/mirislomovmirjalol) + +Thanks to these amazing people, I'm able to dedicate more time to this project and make it better for everyone. +Join them on [GitHub Sponsors](https://github.com/sponsors/franky47)! + +### Contributors + +Huge thanks to [@andreisocaciu](https://github.com/andreisocaciu), [@tordans](https://github.com/tordans), [@prasannamestha](https://github.com/prasannamestha), [@Talent30](https://github.com/Talent30), [@neefrehman](https://github.com/neefrehman), [@chbg](https://github.com/chbg), [@dopry](https://github.com/dopry), [@weisisheng](https://github.com/weisisheng), [@hugotiger](https://github.com/hugotiger), [@iuriizaporozhets](https://github.com/iuriizaporozhets), [@rikbrown](https://github.com/rikbrown), [@mateogianolio](https://github.com/mateogianolio), [@timheerwagen](https://github.com/timheerwagen), [@psdmsft](https://github.com/psdmsft), and [@psdewar](https://github.com/psdewar) for helping! diff --git a/packages/docs/content/docs/about.mdx b/packages/docs/content/docs/about.mdx index 3891cdcb..f7d393f7 100644 --- a/packages/docs/content/docs/about.mdx +++ b/packages/docs/content/docs/about.mdx @@ -29,9 +29,11 @@ Up to you. I say "nukes". _`[njuks]`_ ### What does it mean? -It's the initials of the original name, `Next-UseQueryState`. +> **Never underestimate query strings**. -I realised after the fact that the word `nuqs` is Urdu for "flaw" or "defect". +Kidding aside, it's the initials of the original name, `Next-UseQueryState`. + +I realised after the fact that the word `nuqs` in Urdu & Arabic means "flaw" or "defect". It's a good reminder that: > Perfection is a direction, not a destination. @@ -39,4 +41,7 @@ It's a good reminder that: >
-- [James Wright](https://www.youtube.com/shorts/CH_d9lVRLWk)
I probably should have checked the meaning of the word before using it, -and apologise to any Urdu-speaking user who might find it offensive. +and apologise to any Urdu/Arabic-speaking user who might find it offensive. + +It's also Klingon for "What?!", the kind of reaction you get when you +move from `useState{:ts}` to URL state for the first time. 🖖 diff --git a/packages/docs/content/docs/adapters.mdx b/packages/docs/content/docs/adapters.mdx new file mode 100644 index 00000000..eef47619 --- /dev/null +++ b/packages/docs/content/docs/adapters.mdx @@ -0,0 +1,133 @@ +--- +title: Adapters +description: Using nuqs in your React framework of choice +--- + +Since version 2, you can now use nuqs in the following React frameworks, by +wrapping it with a `NuqsAdapter` context provider: + +- [Next.js (app router)](#nextjs-app-router) +- [Next.js (pages router)](#nextjs-pages-router) +- [React SPA (eg: with Vite)](#react-spa) +- [Remix](#remix) +- [React Router](#react-router) + +## Next.js + +### App router [#nextjs-app-router] + +Wrap your `{children}{:ts}` with the `NuqsAdapter{:ts}` component in your root layout file: + +```tsx title="src/app/layout.tsx" +// [!code word:NuqsAdapter] +import { NuqsAdapter } from 'nuqs/adapters/next/app' +import { type ReactNode } from 'react' + +export default function RootLayout({ + children +}: { + children: ReactNode +}) { + return ( + + + {children} + + + ) +} +``` + +### Pages router [#nextjs-pages-router] + +Wrap the `{:ts}` page outlet with the `NuqsAdapter{:ts}` component in your `_app.tsx` file: + +```tsx title="src/pages/_app.tsx" +// [!code word:NuqsAdapter] +import type { AppProps } from 'next/app' +import { NuqsAdapter } from 'nuqs/adapters/next/pages' + +export default function MyApp({ Component, pageProps }: AppProps) { + return ( + + + + ) +} +``` + +### Unified (router-agnostic) [#nextjs-unified] + +If your Next.js app uses **both the app and pages routers** and the adapter needs +to be mounted in either, you can import the unified adapter, at the cost +of a slightly larger bundle size (~100B). + +```tsx +import { NuqsAdapter } from 'nuqs/adapters/next' +``` + +
+ +The main reason for adapters is to open up nuqs to other React frameworks: + +## React SPA + +Example, with Vite: + +```tsx title="src/main.tsx" +// [!code word:NuqsAdapter] +import { NuqsAdapter } from 'nuqs/adapters/react' + +createRoot(document.getElementById('root')!).render( + + + +) +``` + +## Remix + +```tsx title="app/root.tsx" +// [!code word:NuqsAdapter] +import { NuqsAdapter } from 'nuqs/adapters/remix' + +// ... + +export default function App() { + return ( + + + + ) +} +``` + +## React Router + +```tsx title="src/main.tsx" +// [!code word:NuqsAdapter] +import { NuqsAdapter } from 'nuqs/adapters/react-router' +import { createBrowserRouter, RouterProvider } from 'react-router-dom' +import App from './App' + +const router = createBrowserRouter([ + { + path: '/', + element: + } +]) + +export function ReactRouter() { + return ( + + + + ) +} +``` + +## Testing + + + Documentation for the `NuqsTestingAdapter{:ts}` is on the [testing page](/docs/testing). + diff --git a/packages/docs/content/docs/basic-usage.mdx b/packages/docs/content/docs/basic-usage.mdx index 66634b3e..52e8b576 100644 --- a/packages/docs/content/docs/basic-usage.mdx +++ b/packages/docs/content/docs/basic-usage.mdx @@ -9,6 +9,11 @@ import { GreetingsPrompt } from '@/content/docs/parsers/demos' + + Have you setup your app with the appropriate [**adapter**](./adapters)? Then you + are all set! + + If you are using `React.useState` to manage your local UI state, you can replace it with `useQueryState` to sync it with the URL. diff --git a/packages/docs/content/docs/community-adapters/onejs.mdx b/packages/docs/content/docs/community-adapters/onejs.mdx new file mode 100644 index 00000000..037b94b3 --- /dev/null +++ b/packages/docs/content/docs/community-adapters/onejs.mdx @@ -0,0 +1,83 @@ +--- +title: One +description: Integrate nuqs with One +--- + +[One](https://onestack.dev/) is supported as a community-contributed adapter. + +It's not built-in because it's based on both React web & React Native, and +pulls a lot of dependencies into the nuqs build process (doubling the dependency +install time). + +If it becomes popular and there is sufficient demand, it may be included in the +core package. + +## Step 1: Add the adapter code + + + The custom adapters APIs are not yet stable and may change in the future + in a minor or patch release (not following SemVer). + + +```tsx title="app/nuqs-one-adapter.tsx" +import { + type unstable_AdapterOptions as AdapterOptions, + unstable_createAdapterProvider as createAdapterProvider, + renderQueryString +} from 'nuqs/adapters/custom' +import { useActiveParams, useRouter } from 'one' + +function useNuqsOneAdapter() { + const router = useRouter() + const searchParams = new URLSearchParams(useActiveParams() as {}) + const updateUrl = (search: URLSearchParams, options: AdapterOptions) => { + if (options.history === 'push') { + router.push(renderQueryString(search), { + scroll: options.scroll + }) + } else { + router.replace(renderQueryString(search), { + scroll: options.scroll + }) + } + } + return { + searchParams, + updateUrl + } +} + +export const NuqsAdapter = createAdapterProvider(useNuqsOneAdapter) + +``` + +## Step 2: wrap your main route + +Integrate the adapter into the root layout file, by wrapping the `` +component: + +```tsx title="app/_layout.tsx" /NuqsAdapter/ +import { NuqsAdapter } from './nuqs-one-adapter' +import { Slot } from 'one' + +export default function Layout() { + return ( + <> + {typeof document !== 'undefined' && ( + <> + + + + + + )} + + + + + ) +} +``` diff --git a/packages/docs/content/docs/installation.mdx b/packages/docs/content/docs/installation.mdx index 1a4003bd..cfef17d2 100644 --- a/packages/docs/content/docs/installation.mdx +++ b/packages/docs/content/docs/installation.mdx @@ -23,18 +23,14 @@ bun add nuqs ## Which version should I use? -| Next.js version range | Supported `nuqs` / `next-usequerystate` version | -| --------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| >=14.0.4 | `nuqs@latest` | -| 14.0.3 | `nuqs@latest`, with the `windowHistorySupport` experimental flag, see [#417](https://github.com/47ng/nuqs/issues/417) | -| 14.0.2 | Not compatible, see issue [#388](https://github.com/47ng/nuqs/issues/388) and Next.js PR [#58297](https://github.com/vercel/next.js/pull/58297) | -| >= 13.1 && \<\= 14.0.1 | `nuqs@latest` | -| < 13.1 | `next-usequerystate@1.7.3` | - -What happened to `next-usequerystate`?}> -It was a mouthful to type, so I decided to abbreviate it to `nuqs`. - -The `nuqs` name was introduced in 1.14.0, and `next-usequerystate` will mirror -its versions for the rest of the 1.x.x range. The next major version update -(2.0.0) and subsequent versions will only be published under the `nuqs` name. +`nuqs@^2` supports the following frameworks and their respective versions: + +- [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 + + +For older versions of Next.js, you may use `nuqs@^1` (documentation in the README). + diff --git a/packages/docs/content/docs/meta.json b/packages/docs/content/docs/meta.json index 8505e6f4..dc629f7a 100644 --- a/packages/docs/content/docs/meta.json +++ b/packages/docs/content/docs/meta.json @@ -2,18 +2,23 @@ "title": "Documentation", "root": true, "pages": [ + "---Getting started---", "installation", + "adapters", "basic-usage", + "---Guide---", "parsers", "options", "batching", "server-side", + "---Going further---", "utilities", "debugging", "testing", "troubleshooting", "seo", "tips-tricks", - "about" + "about", + "migrations/v2" ] } diff --git a/packages/docs/content/docs/migrations/v2.mdx b/packages/docs/content/docs/migrations/v2.mdx new file mode 100644 index 00000000..49312470 --- /dev/null +++ b/packages/docs/content/docs/migrations/v2.mdx @@ -0,0 +1,255 @@ +--- +title: Migration guide to v2 +description: How to update your code to use nuqs@2.0.0 +--- + +Here's a summary of the breaking changes in `nuqs@2.0.0`: + +- [Enable support for other React frameworks via **adapters**](#adapters) +- [Behaviour changes](#behaviour-changes) +- [ESM-only package](#esm-only) +- [Deprecated exports have been removed](#deprecated-exports) +- [Renamed `nuqs/parsers` to `nuqs/server`](#renamed-nuqsparsers-to-nuqsserver) +- [Debug printout detection](#debug-printout-detection) +- [Dropping `next-usequerystate`](#dropping-next-usequerystate) +- [Type changes](#type-changes) + +## Adapters + +The biggest change is that `nuqs@2.0.0` now supports other React frameworks, +providing type-safe URL state for all. + +You will need to wrap your app with the appropriate [adapter](/docs/adapters) +for your framework or router, to let the hooks know how to interact with it. + +Adapters are currently available for: +- Next.js (app & pages routers) +- React SPA +- Remix +- React Router +- Testing environments (Vitest, Jest, etc.) + +If you are coming from nuqs v1 (which only supported Next.js), you'll need to +wrap your app with the appropriate `NuqsAdapter`: + +### Next.js + + + +Early versions of Next.js 14 were in flux with regards to shallow routing. +Supporting those earlier versions required a lot of hacks, workarounds, and +performance penalties, which were removed in `nuqs@2.0.0`. + + + +#### App router + +```tsx {1} /NuqsAdapter/ title="src/app/layout.tsx" +import { NuqsAdapter } from 'nuqs/adapters/next/app' +import { type ReactNode } from 'react' + +export default function RootLayout({ + children +}: { + children: ReactNode +}) { + return ( + + + {children} + + + ) +} +``` + +#### Pages router + +```tsx {2} /NuqsAdapter/ title="src/pages/_app.tsx" +import type { AppProps } from 'next/app' +import { NuqsAdapter } from 'nuqs/adapters/next/pages' + +export default function MyApp({ Component, pageProps }: AppProps) { + return ( + + + + ) +} +``` + +#### Unified (router-agnostic) + +If your Next.js app uses **both the app and pages routers** and the adapter needs +to be mounted in either, you can import the unified adapter, at the cost +of a slightly larger bundle size (~100B). + +```tsx +import { NuqsAdapter } from 'nuqs/adapters/next' +``` + +### Other adapters + +Albeit not part of a migration from v1, you can now use nuqs in other React +frameworks via their respective [adapters](/docs/adapters). + +However, there's one more adapter that might be of interest to you, and solves +a long-standing issue with testing components using nuqs hooks: + +### Testing adapter + +Unit-testing components that used nuqs v1 was a hassle, as it required mocking +the Next.js router internals, causing abstraction leaks. + +In v2, you can now wrap your components to test with the [`NuqsTestingAdapter`](/docs/testing), +which provides a convenient setup & assertion API for your tests. + +Here's an example with Vitest & Testing Library: + +```tsx title="counter-button-example.test.tsx" +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { NuqsTestingAdapter, type UrlUpdateEvent } from 'nuqs/adapters/testing' +import { describe, expect, it, vi } from 'vitest' +import { CounterButton } from './counter-button' + +it('should increment the count when clicked', async () => { + const user = userEvent.setup() + const onUrlUpdate = vi.fn<[UrlUpdateEvent]>() + render(, { + // Setup the test by passing initial search params / querystring: + wrapper: ({ children }) => ( + + {children} + + ) + }) + // Act + const button = screen.getByRole('button') + await user.click(button) + // Assert changes in the state and in the (mocked) URL + expect(button).toHaveTextContent('count is 2') + expect(onUrlUpdate).toHaveBeenCalledOnce() + expect(onUrlUpdate.mock.calls[0][0].queryString).toBe('?count=2') + expect(onUrlUpdate.mock.calls[0][0].searchParams.get('count')).toBe('2') + expect(onUrlUpdate.mock.calls[0][0].options.history).toBe('push') +}) +``` + +## Behaviour changes + +Setting the `startTransition{:ts}` option no longer sets `shallow: false{:ts}` automatically. +This is to align with other frameworks that don't have a concept +of shallow/deep routing. + +You'll have to set both to keep sending updates to the server and getting a loading +state in Next.js: + +```diff +useQueryState('q', { + startTransition: true, ++ shallow: false +}) +``` + +The `"use client"{:ts}` directive was not included in the client import +(`import {} from 'nuqs'{:ts}`). It has now been added, meaning that server-side code +needs to import from `nuqs/server` to avoid errors like: + +```txt +Error: Attempted to call withDefault() from the server but withDefault is on +the client. It's not possible to invoke a client function from the server, it can +only be rendered as a Component or passed to props of a Client +Component. +``` + +## ESM only + +`nuqs@2.0.0` is now an [ESM-only](https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c) +package. This should not be much of an issue since Next.js supports ESM in +app code since version 12, but if you are bundling `nuqs` code into an +intermediate CJS library to be consumed in Next.js, you'll run into import issues: + +```txt +[ERR_REQUIRE_ESM]: require() of ES Module not supported +``` + +If converting your library to ESM is not possible, your main option is to +dynamically import `nuqs`: + +```ts +const { useQueryState } = await import('nuqs') +``` + +## Deprecated exports + +Some of the v1 API was marked as deprecated back in September 2023, and has been +removed in `nuqs@2.0.0`. + +### `queryTypes` parsers object + +The `queryTypes{:ts}` object has been removed in favor of individual parser exports, +for better tree-shaking. + +Replace with `parseAsXYZ{:ts}` to match: + +```diff +- import { queryTypes } from 'nuqs' ++ import { parseAsString, parseAsInteger, ... } from 'nuqs' + +- useQueryState('q', queryTypes.string.withOptions({ ... })) +- useQueryState('page', queryTypes.integer.withDefault(1)) ++ useQueryState('q', parseAsString.withOptions({ ... })) ++ useQueryState('page', parseAsInteger.withDefault(1)) +``` + +### `subscribeToQueryUpdates` + +Next.js 14.1.0 makes `useSearchParams{:ts}` reactive to shallow search params updates, +which makes this internal helper function redundant. See [#425](https://github.com/47ng/nuqs/pull/425) for context. + +## Renamed `nuqs/parsers` to `nuqs/server` + +When introducing the server cache in [#397](https://github.com/47ng/nuqs/pull/397), the dedicated export for parsers was +reused as it didn't include the `"use client"{:ts}` directive. Since it now contains +more than parsers and probably will be extended with server-only code in the future, +it has been renamed to a clearer export name. + +Find and replace all occurrences of `nuqs/parsers` to `nuqs/server` in your code: + +```diff +- import { parseAsInteger, createSearchParamsCache } from 'nuqs/parsers' ++ import { parseAsInteger, createSearchParamsCache } from 'nuqs/server' +``` + +## Debug printout detection + +After the rename to `nuqs`, the debugging printout detection logic handled either +`next-usequerystate` or `nuqs` being present in the `localStorage.debug{:ts}` variable. +`nuqs@2.0.0` only checks for the presence of the `nuqs` substring to enable logs. + +Update your local dev environments to match by running this once in the devtools console: + +```ts +if (localStorage.debug) { + localStorage.debug = localStorage.debug.replace('next-usequerystate', 'nuqs') +} +``` + +## Dropping next-usequerystate + +This package started under the name `next-usequerystate`, and was renamed to +`nuqs` in January 2024. The old package name was kept as an alias for the v1 +release line. + +`nuqs` version 2 and onwards no longer mirror to the `next-usequerystate` package name. + +## Type changes + +The following breaking changes only apply to exported types: + +- The `Options{:ts}` type is no longer generic +- The `UseQueryStatesOptions{:ts}` is now a type rather than an interface, and is now +generic over the type of the object you pass to `useQueryStates{:ts}`. +- [`parseAsJson{:ts}`](/docs/parsers/built-in#json) now requires a runtime +validation function to infer the type of the parsed JSON data. diff --git a/packages/docs/content/docs/options.mdx b/packages/docs/content/docs/options.mdx index f958ae0a..18dbd1e4 100644 --- a/packages/docs/content/docs/options.mdx +++ b/packages/docs/content/docs/options.mdx @@ -125,12 +125,12 @@ internal state and stay in sync with each other. ## Transitions -When combined with `shallow: false{:ts}`, you can use React's `useTransition` hook +When combined with `shallow: false{:ts}`, you can use React's `useTransition{:ts}` hook to get loading states while the server is re-rendering server components with the updated URL. -Pass in the `startTransition` function from `useTransition` to the options -to enable this behaviour _(this will set `shallow: false{:ts}` automatically for you)_: +Pass in the `startTransition{:ts}` function from `useTransition{:ts}` to the options +to enable this behaviour: In `nuqs@2.0.0`, passing `startTransition` will no longer automatically set `shallow: false{:ts}`. @@ -144,11 +144,13 @@ import { useQueryState, parseAsString } from 'nuqs' function ClientComponent({ data }) { // 1. Provide your own useTransition hook: + // [!code word:startTransition:1] const [isLoading, startTransition] = React.useTransition() const [query, setQuery] = useQueryState( 'query', // 2. Pass the `startTransition` as an option: - parseAsString().withOptions({ startTransition }) + // [!code word:startTransition:1] + parseAsString().withOptions({ startTransition, shallow: false }) ) // 3. `isLoading` will be true while the server is re-rendering // and streaming RSC payloads, when the query is updated via `setQuery`. @@ -161,24 +163,34 @@ function ClientComponent({ data }) { } ``` + + In `nuqs@1.x.x`, passing `startTransition` as an option automatically sets + `shallow: false{:ts}`. + + This is no longer the case in `nuqs@>=2.0.0`: you'll need to set it explicitly. + + ## Clear on default -By default, when the state is set to the default value, the search parameter is -**not** removed from the URL, and is reflected explicitly. This is because -**default values _can_ change**, and the meaning of the URL along with it. +When the state is set to the default value, the search parameter is +removed from the URL, instead of being reflected explicitly. - - In `nuqs@2.0.0`, clearOnDefault will be set to `true{:ts}` by default, in response +However, sometimes you might want to keep the search parameter in the URL, +because **default values _can_ change**, and the meaning of the URL along with it. + + + In `nuqs@1.x.x`, `clearOnDefault{:ts}` was `false{:ts}` by default.
+ in `nuqs@2.0.0`, `clearOnDefault{:ts}` is now `true{:ts}` by default, in response to [user feedback](https://x.com/fortysevenfx/status/1841610237540696261).
-If you want to remove the search parameter from the URL when it's set to the default -value, you can set `clearOnDefault` to `true{:ts}`: +If you want to keep the search parameter in the URL when it's set to the default +value, you can set `clearOnDefault` to `false{:ts}`: ```ts /clearOnDefault: true/ useQueryState('search', { defaultValue: '', - clearOnDefault: true + clearOnDefault: false }) ``` @@ -186,15 +198,15 @@ useQueryState('search', { Clearing the key-value pair from the query string can always be done by setting the state to `null{:ts}`.
-This option compares the set state against the default value using `===` reference -equality, so if you are using a [custom parser](./parsers/making-your-own) +This option compares the set state against the default value using `==={:ts}` +reference equality, so if you are using a [custom parser](./parsers/making-your-own) for a state type that wouldn't work with reference equality, you should provide -the `eq` function to your parser (this is done for you in built-in parsers): +the `eq{:ts}` function to your parser (this is done for you in built-in parsers): ```ts const dateParser = createParser({ parse: (value: string) => new Date(value.slice(0, 10)), serialize: (date: Date) => date.toISOString().slice(0, 10), - eq: (a: Date, b: Date) => a.getTime() === b.getTime() + eq: (a: Date, b: Date) => a.getTime() === b.getTime() // [!code highlight] }) ``` diff --git a/packages/docs/content/docs/parsers/built-in.mdx b/packages/docs/content/docs/parsers/built-in.mdx index 3aafb059..3afb7618 100644 --- a/packages/docs/content/docs/parsers/built-in.mdx +++ b/packages/docs/content/docs/parsers/built-in.mdx @@ -222,11 +222,23 @@ parseAsArrayOf(parseAsInteger, ';') If primitive types are not enough, you can encode JSON in the query string. -```ts /parseAsJson()/ +Pass it a validation function to validate and infer the type of the parsed data, +like a [Zod](https://zod.dev) schema: + +```ts import { parseAsJson } from 'nuqs' +import { z } from 'zod' + +const schema = z.object({ + pkg: z.string(), + version: z.number(), + worksWith: z.array(z.string()) +}) -// This parser is a function, don't forget to call it: -const [json, setJson] = useQueryState('json', parseAsJson()) +// This parser is a function, don't forget to call it with the parse function +// as an argument. +// [!code word:parseAsJson()] +const [json, setJson] = useQueryState('json', parseAsJson(schema.parse)) setJson({ pkg: "nuqs", @@ -239,25 +251,8 @@ setJson({ -### Validation with Zod - -Note that by itself, `parseAsJson` isn't type-safe, and will return `unknown{:ts}`, -as the value could be anything. - -You can pass it an optional callback argument to validate the parsed data. -Using a Zod schema looks like this: - -```ts -const zodSchema = z.object({ - foo: z.string(), - bar: z.number() -}) - -const [obj, setObj] = useQueryState('zod', parseAsJson(zodSchema.parse)) -``` - Using other validation libraries is possible, as long as they throw an error -when the data is invalid. +when the data is invalid (eg: Valibot, Yup, etc). ## Using parsers server-side diff --git a/packages/docs/content/docs/parsers/demos.tsx b/packages/docs/content/docs/parsers/demos.tsx index be0a18be..5e34a5e6 100644 --- a/packages/docs/content/docs/parsers/demos.tsx +++ b/packages/docs/content/docs/parsers/demos.tsx @@ -21,6 +21,7 @@ import { useQueryState } from 'nuqs' import React from 'react' +import { z } from 'zod' export function DemoFallback() { return ( @@ -297,8 +298,17 @@ export function DateTimestampParserDemo() { return } +const jsonParserSchema = z.object({ + pkg: z.string(), + version: z.number(), + worksWith: z.array(z.string()) +}) + export function JsonParserDemo() { - const [value, setValue] = useQueryState('json', parseAsJson()) + const [value, setValue] = useQueryState( + 'json', + parseAsJson(jsonParserSchema.parse) + ) return (
diff --git a/packages/docs/content/docs/server-side.mdx b/packages/docs/content/docs/server-side.mdx
index 2ceb2220..bfd20833 100644
--- a/packages/docs/content/docs/server-side.mdx
+++ b/packages/docs/content/docs/server-side.mdx
@@ -3,8 +3,12 @@ title: Server-Side usage
 description: Type-safe search params on the server
 ---
 
+
+  This feature is available for Next.js only.
+
+
 If you wish to access the searchParams in a deeply nested Server Component
-(ie: not in the Page component), you can use `createSearchParamsCache`
+(ie: not in the Page component), you can use `createSearchParamsCache{:ts}`
 to do so in a type-safe manner.
 
 
@@ -32,15 +36,16 @@ export const searchParamsCache = createSearchParamsCache({
 
 ```tsx title="page.tsx"
 import { searchParamsCache } from './searchParams'
+import { type SearchParams } from 'nuqs/server'
+
+type PageProps = {
+  searchParams: Promise // Next.js 15+: async searchParams prop
+}
 
-export default function Page({
-  searchParams
-}: {
-  searchParams: Record
-}) {
+export default async function Page({ searchParams }: PageProps) {
   // ⚠️ Don't forget to call `parse` here.
   // You can access type-safe values from the returned object:
-  const { q: query } = searchParamsCache.parse(searchParams)
+  const { q: query } = searchParamsCache.parse(await searchParams)
   return (
     

Search Results for {query}

@@ -81,8 +86,8 @@ import { coordinatesCache } from './searchParams' import { Server } from './server' import { Client } from './client' -export default function Page({ searchParams }) { - coordinatesCache.parse(searchParams) +export default async function Page({ searchParams }) { + coordinatesCache.parse(await searchParams) return ( <> diff --git a/packages/docs/content/docs/test.mdx b/packages/docs/content/docs/test.mdx deleted file mode 100644 index 5ea2d3e8..00000000 --- a/packages/docs/content/docs/test.mdx +++ /dev/null @@ -1,84 +0,0 @@ ---- -title: Test -description: Markdown sandbox ---- - -```tsx title="Short" -// [!code word:Test] -// [!code word:Page] -export default async function Page() { - return
Test
-} -``` - -```tsx title="Long" custom="compact" -// [!code word:Lorem] -Lorem deserunt dolore amet elit culpa mollit elit nostrud consequat qui nostrud - -Ex nisi do magna pariatur // [!code highlight] -Ipsum pariatur laborum et nisi et amet do amet commodo - -Nulla esse ullamco ea ullamco non mollit commodo sunt - -Consequat excepteur est irure incididunt pariatur sit nulla incididunt fugiat ipsum eiusmod ex Lorem aute. // [!code highlight:3] -Sunt excepteur deserunt ea ea ullamco do -Culpa voluptate anim consequat nisi aliquip Lorem occaecat excepteur aliquip non deserunt -Sit esse ex eiusmod quis ea minim adipisicing adipisicing mollit fugiat nulla dolore excepteur labore. - -Anim aliqua et occaecat deserunt cillum -Aliquip adipisicing duis magna reprehenderit -Elit aliquip fugiat excepteur excepteur aliquip eu pariatur officia sunt deserunt elit -Ex do occaecat veniam minim veniam cillum id deserunt ea in commodo voluptate ullamco ea reprehenderit -Et dolore aute dolore ullamco laborum elit Lorem. - -Reprehenderit ut sunt nisi magna quis do voluptate veniam occaecat esse incididunt excepteur adipisicing minim -Do ea sunt tempor adipisicing elit -Deserunt dolor adipisicing nostrud mollit duis ullamco ut nulla aliquip do -Nostrud in velit cillum do est ipsum -Ea cillum amet culpa quis proident excepteur eiusmod incididunt dolor sunt Lorem velit -Duis qui dolor eu -Deserunt consequat magna fugiat aliqua aute officia esse sit veniam consectetur excepteur do officia exercitation esse. - -Sunt exercitation amet sint voluptate cupidatat aliqua commodo velit irure -Anim in ex culpa ea culpa -Aute ad deserunt ex in culpa aliquip enim qui nisi consectetur ex exercitation duis occaecat adipisicing -Laboris elit non ut deserunt enim irure tempor fugiat ad eu magna dolore occaecat -Elit ullamco proident consequat labore eiusmod occaecat culpa duis dolore amet non minim labore -Aliqua laboris deserunt eiusmod culpa nisi laborum ut minim excepteur excepteur laboris tempor culpa. - -Sit minim magna id sint occaecat dolor dolore cupidatat anim nisi ut -Nulla reprehenderit mollit cupidatat ut labore magna officia deserunt -Esse est ullamco minim consectetur proident proident ex deserunt ipsum -Ea nostrud mollit ad incididunt enim consequat eiusmod commodo amet sunt. - -Irure reprehenderit nisi laboris esse occaecat anim cillum sunt anim officia proident -Commodo fugiat cupidatat eu aliquip exercitation tempor occaecat duis fugiat excepteur -Fugiat proident cillum pariatur amet in et -Magna labore culpa duis commodo qui reprehenderit dolore qui cillum in in pariatur -Eu sunt elit culpa amet nulla amet reprehenderit aliquip irure minim id tempor ullamco. - -Eu deserunt esse cillum sunt aute dolore eiusmod velit sint dolore ullamco sit duis proident ut -Amet sint reprehenderit exercitation labore ad officia sint aliquip ea -Sint est qui cupidatat amet consectetur ipsum labore dolor aute duis aliqua ut fugiat et -Dolor ullamco reprehenderit adipisicing laborum consequat officia aute nostrud qui ut aliqua velit velit -Dolore dolore cupidatat nisi aute officia cillum commodo. - -Proident quis reprehenderit incididunt sunt reprehenderit occaecat Lorem commodo aliquip -Mollit ea magna est ea ullamco do officia qui exercitation sint sunt -Eiusmod ex aliquip cupidatat qui nulla duis voluptate minim deserunt pariatur laborum eiusmod reprehenderit voluptate aliqua -Esse dolor commodo fugiat labore pariatur laboris sit amet incididunt. - -Eiusmod sint adipisicing voluptate qui nulla pariatur sit et magna ut -Laboris excepteur laboris Lorem elit nulla labore aliquip quis nisi -In qui est ut ea aliquip consequat est non aute -Eiusmod voluptate minim enim proident deserunt Lorem ullamco id. - -Incididunt reprehenderit ipsum cillum anim eiusmod magna esse Lorem dolore culpa nostrud -Proident ea qui nulla deserunt velit -Lorem pariatur ea elit veniam dolore qui et officia cupidatat ea ullamco mollit non culpa anim -Do duis mollit fugiat -Ad Lorem ipsum in laborum mollit -Occaecat sit deserunt magna culpa aliquip cupidatat -Incididunt sit nisi elit excepteur eiusmod fugiat anim id duis sit -Ullamco amet pariatur ea duis veniam ea mollit. -``` diff --git a/packages/docs/content/docs/testing.mdx b/packages/docs/content/docs/testing.mdx index 8d6f1d67..0401755f 100644 --- a/packages/docs/content/docs/testing.mdx +++ b/packages/docs/content/docs/testing.mdx @@ -3,11 +3,41 @@ title: Testing description: Some tips on testing components that use `nuqs` --- -Currently, the best way to test the behaviour of your components using -`useQueryState(s)` is end-to-end testing, with tools like Playwright or Cypress. +Since nuqs 2, you can unit-test components that use `useQueryState(s){:ts}` hooks +by wrapping your rendered component in a `NuqsTestingAdapter{:ts}`. -Running components that use the Next.js router in isolation requires mocking it, -which is being [worked on](https://github.com/scottrippey/next-router-mock/pull/103) -for the app router. +Here is an example for Vitest and Testing Library to test a button rendering +a counter: + +```tsx title="counter-button.test.tsx" +// [!code word:NuqsTestingAdapter] +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { NuqsTestingAdapter, type UrlUpdateEvent } from 'nuqs/adapters/testing' +import { describe, expect, it, vi } from 'vitest' +import { CounterButton } from './counter-button' + +it('should increment the count when clicked', async () => { + const user = userEvent.setup() + const onUrlUpdate = vi.fn<[UrlUpdateEvent]>() + render(, { + // 1. Setup the test by passing initial search params / querystring: + wrapper: ({ children }) => ( + + {children} + + ) + }) + // 2. Act + const button = screen.getByRole('button') + await user.click(button) + // 3. Assert changes in the state and in the (mocked) URL + expect(button).toHaveTextContent('count is 43') + expect(onUrlUpdate).toHaveBeenCalledOnce() + expect(onUrlUpdate.mock.calls[0][0].queryString).toBe('?count=43') + expect(onUrlUpdate.mock.calls[0][0].searchParams.get('count')).toBe('43') + expect(onUrlUpdate.mock.calls[0][0].options.history).toBe('push') +}) +``` See issue [#259](https://github.com/47ng/nuqs/issues/259) for more testing-related discussions. diff --git a/packages/docs/next.config.mjs b/packages/docs/next.config.mjs index c30e48fa..e887b022 100644 --- a/packages/docs/next.config.mjs +++ b/packages/docs/next.config.mjs @@ -27,6 +27,11 @@ const config = { destination: '/docs/installation', permanent: false }, + { + source: '/docs/parsers/community', + destination: '/docs/parsers/community/tanstack-table', + permanent: false + }, // Cool URLs don't break { source: '/docs/parsers', diff --git a/packages/docs/package.json b/packages/docs/package.json index fdde2bda..5bb56cac 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -3,6 +3,16 @@ "version": "0.0.0-internal", "private": true, "type": "module", + "author": { + "name": "François Best", + "email": "contact@francoisbest.com", + "url": "https://francoisbest.com" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/47ng/nuqs.git", + "directory": "packages/docs" + }, "scripts": { "dev": "next dev", "build": "next build", diff --git a/packages/docs/public/og/about.jpg b/packages/docs/public/og/about.jpg index 488c719a..ac0bf52d 100644 Binary files a/packages/docs/public/og/about.jpg and b/packages/docs/public/og/about.jpg differ diff --git a/packages/docs/public/og/adapters.jpg b/packages/docs/public/og/adapters.jpg new file mode 100644 index 00000000..2030b615 Binary files /dev/null and b/packages/docs/public/og/adapters.jpg differ diff --git a/packages/docs/public/og/basic-usage.jpg b/packages/docs/public/og/basic-usage.jpg index 0195669f..31d39d2a 100644 Binary files a/packages/docs/public/og/basic-usage.jpg and b/packages/docs/public/og/basic-usage.jpg differ diff --git a/packages/docs/public/og/batching.jpg b/packages/docs/public/og/batching.jpg index b94e01d8..f4b1366b 100644 Binary files a/packages/docs/public/og/batching.jpg and b/packages/docs/public/og/batching.jpg differ diff --git a/packages/docs/public/og/debugging.jpg b/packages/docs/public/og/debugging.jpg index e36174e0..0523cf92 100644 Binary files a/packages/docs/public/og/debugging.jpg and b/packages/docs/public/og/debugging.jpg differ diff --git a/packages/docs/public/og/installation.jpg b/packages/docs/public/og/installation.jpg index 4ed8aa02..3b6c5457 100644 Binary files a/packages/docs/public/og/installation.jpg and b/packages/docs/public/og/installation.jpg differ diff --git a/packages/docs/public/og/migrations/v2.jpg b/packages/docs/public/og/migrations/v2.jpg new file mode 100644 index 00000000..88475ee4 Binary files /dev/null and b/packages/docs/public/og/migrations/v2.jpg differ diff --git a/packages/docs/public/og/options.jpg b/packages/docs/public/og/options.jpg index ee6a1d93..fc9a7bd3 100644 Binary files a/packages/docs/public/og/options.jpg and b/packages/docs/public/og/options.jpg differ diff --git a/packages/docs/public/og/parsers.jpg b/packages/docs/public/og/parsers.jpg index 99e24ce5..a769e039 100644 Binary files a/packages/docs/public/og/parsers.jpg and b/packages/docs/public/og/parsers.jpg differ diff --git a/packages/docs/public/og/parsers/built-in.jpg b/packages/docs/public/og/parsers/built-in.jpg new file mode 100644 index 00000000..a769e039 Binary files /dev/null and b/packages/docs/public/og/parsers/built-in.jpg differ diff --git a/packages/docs/public/og/parsers/making-your-own.jpg b/packages/docs/public/og/parsers/making-your-own.jpg new file mode 100644 index 00000000..a7f7349e Binary files /dev/null and b/packages/docs/public/og/parsers/making-your-own.jpg differ diff --git a/packages/docs/public/og/seo.jpg b/packages/docs/public/og/seo.jpg index c7cbcfbd..d14566cd 100644 Binary files a/packages/docs/public/og/seo.jpg and b/packages/docs/public/og/seo.jpg differ diff --git a/packages/docs/public/og/server-side.jpg b/packages/docs/public/og/server-side.jpg index f28f456e..8d6815b3 100644 Binary files a/packages/docs/public/og/server-side.jpg and b/packages/docs/public/og/server-side.jpg differ diff --git a/packages/docs/public/og/testing.jpg b/packages/docs/public/og/testing.jpg index 89cd48cd..f9732f06 100644 Binary files a/packages/docs/public/og/testing.jpg and b/packages/docs/public/og/testing.jpg differ diff --git a/packages/docs/public/og/tips-tricks.jpg b/packages/docs/public/og/tips-tricks.jpg index ae8343b3..36246002 100644 Binary files a/packages/docs/public/og/tips-tricks.jpg and b/packages/docs/public/og/tips-tricks.jpg differ diff --git a/packages/docs/public/og/utilities.jpg b/packages/docs/public/og/utilities.jpg index d341a8df..e3f433c9 100644 Binary files a/packages/docs/public/og/utilities.jpg and b/packages/docs/public/og/utilities.jpg differ diff --git a/packages/docs/source.config.ts b/packages/docs/source.config.ts index f8d93f84..96428099 100644 --- a/packages/docs/source.config.ts +++ b/packages/docs/source.config.ts @@ -1,6 +1,12 @@ import { rehypeCode } from 'fumadocs-core/mdx-plugins' -import { defineConfig, defineDocs } from 'fumadocs-mdx/config' +import { + defineCollections, + defineConfig, + defineDocs, + frontmatterSchema +} from 'fumadocs-mdx/config' import remarkSmartypants from 'remark-smartypants' +import { z } from 'zod' import { rehypeCodeOptions } from './rehype-code.config' export default defineConfig({ @@ -13,3 +19,12 @@ export default defineConfig({ }) export const { docs, meta } = defineDocs() + +export const blog = defineCollections({ + dir: 'content/blog', + schema: frontmatterSchema.extend({ + author: z.string(), + date: z.string().date().or(z.date()).optional() + }), + type: 'doc' +}) diff --git a/packages/docs/src/app/(pages)/_landing/demo.tsx b/packages/docs/src/app/(pages)/_landing/demo.tsx index cfe87939..36ebcd50 100644 --- a/packages/docs/src/app/(pages)/_landing/demo.tsx +++ b/packages/docs/src/app/(pages)/_landing/demo.tsx @@ -14,7 +14,6 @@ export async function LandingDemo() { !line.includes('className="') && !line.includes('data-interacted=') ) .join('\n') - .replaceAll('next-usequerystate', 'nuqs') return ( <> ) { } title="Universal" - description="Supports both the app router and pages router." + description="Supports Next.js (app & pages routers), React SPA, Remix, React Router, and more." + isNew /> } @@ -87,13 +88,11 @@ export function FeaturesSection(props: React.ComponentProps<'section'>) { icon={} title="Server cache" description="Type-safe search params access in nested React Server Components. No prop drilling needed." - isNew /> } title="Transition" description="Support for useTransition to get loading states on server updates." - isNew /> } @@ -113,11 +112,11 @@ export function FeaturesSection(props: React.ComponentProps<'section'>) { icon={} title={ - Tested + Tested & testable } - description="Tested against every Next.js release." + description="Tested against every Next.js release. Use the provided test adapter to test your components in isolation." /> ) diff --git a/packages/docs/src/app/(pages)/_landing/hero.tsx b/packages/docs/src/app/(pages)/_landing/hero.tsx index 1f4f2055..d523e8f7 100644 --- a/packages/docs/src/app/(pages)/_landing/hero.tsx +++ b/packages/docs/src/app/(pages)/_landing/hero.tsx @@ -15,7 +15,7 @@ export function HeroSection() {

Type-safe search params
- state manager for Next.js + state manager for React