From bfd846af04e44e98d5dca988cb2ea28ce84d7642 Mon Sep 17 00:00:00 2001 From: Francois Best Date: Mon, 15 Jan 2024 22:31:45 +0100 Subject: [PATCH 01/34] chore: ESM only (drop CJS support) BREAKING CHANGE: drop CJS support. Since Next has ESM support since v12, it should not really be a breaking change for most. --- packages/nuqs/package.json | 11 ++++------- packages/nuqs/scripts/prepack.sh | 4 ++-- packages/nuqs/tsup.config.ts | 2 +- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/packages/nuqs/package.json b/packages/nuqs/package.json index 2f8b509db..4c346393a 100644 --- a/packages/nuqs/package.json +++ b/packages/nuqs/package.json @@ -33,14 +33,12 @@ ], "type": "module", "sideEffects": true, - "main": "dist/index.cjs", "module": "dist/index.js", "types": "dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "require": "./dist/index.cjs" + "import": "./dist/index.js" }, "./server": { "types": "./dist/server.d.ts", @@ -49,8 +47,7 @@ }, "./parsers": { "types": "./dist/parsers.d.ts", - "import": "./dist/parsers.js", - "require": "./dist/parsers.cjs" + "import": "./dist/parsers.js" } }, "scripts": { @@ -90,7 +87,7 @@ }, "size-limit": [ { - "name": "Client (ESM)", + "name": "Client", "path": "dist/index.js", "limit": "5 kB", "ignore": [ @@ -98,7 +95,7 @@ ] }, { - "name": "Server (ESM)", + "name": "Server", "path": "dist/server.js", "limit": "2 kB", "ignore": [ diff --git a/packages/nuqs/scripts/prepack.sh b/packages/nuqs/scripts/prepack.sh index f6d23cd45..08f84826d 100755 --- a/packages/nuqs/scripts/prepack.sh +++ b/packages/nuqs/scripts/prepack.sh @@ -13,8 +13,8 @@ VERSION=$(cat package.json | jq -r '.version') if [[ "$(uname)" == "Darwin" ]]; then # macOS requires an empty string as the backup extension - sed -i '' "s/0.0.0-inject-version-here/${VERSION}/g" dist/index.{js,cjs} + sed -i '' "s/0.0.0-inject-version-here/${VERSION}/g" dist/index.js else # Ubuntu (CI/CD) doesn't - sed -i "s/0.0.0-inject-version-here/${VERSION}/g" dist/index.{js,cjs} + sed -i "s/0.0.0-inject-version-here/${VERSION}/g" dist/index.js fi diff --git a/packages/nuqs/tsup.config.ts b/packages/nuqs/tsup.config.ts index 7a67fcc48..f48368a0f 100644 --- a/packages/nuqs/tsup.config.ts +++ b/packages/nuqs/tsup.config.ts @@ -6,7 +6,7 @@ export default defineConfig({ parsers: 'src/index.parsers.ts', server: 'src/index.server.ts' }, - format: ['esm', 'cjs'], + format: ['esm'], dts: true, outDir: 'dist', splitting: true, From b23e07a7681c42e26bf4ad427fe3b2782824cdd9 Mon Sep 17 00:00:00 2001 From: Francois Best Date: Mon, 15 Jan 2024 22:54:50 +0100 Subject: [PATCH 02/34] chore: Remove deprecated APIs BREAKING CHANGE: the following deprecated APIs have been removed: - `queryTypes` bag of parsers -> use individual `parseAsXYZ` parsers for better tree-shakeability. - `subscribeToQueryUpdates` helper -> since Next.js 14.0.5, `useSearchParams` is reactive to shallow search params updates in the app router. See #425. - Internal types and aliases --- .../_demos/subscribeToQueryUpdates/page.tsx | 36 --- .../src/pages/pages/useQueryState/index.tsx | 16 +- .../src/pages/pages/useQueryStates/index.tsx | 16 +- packages/nuqs/src/deprecated.ts | 70 ----- packages/nuqs/src/index.ts | 2 - packages/nuqs/src/sync.ts | 15 - .../src/tests/compat/useQueryState.test-d.ts | 257 ------------------ .../src/tests/compat/useQueryStates.test-d.ts | 72 ----- packages/nuqs/src/useQueryState.ts | 14 +- packages/nuqs/src/useQueryStates.ts | 2 +- 10 files changed, 30 insertions(+), 470 deletions(-) delete mode 100644 packages/docs/src/app/playground/_demos/subscribeToQueryUpdates/page.tsx delete mode 100644 packages/nuqs/src/deprecated.ts delete mode 100644 packages/nuqs/src/tests/compat/useQueryState.test-d.ts delete mode 100644 packages/nuqs/src/tests/compat/useQueryStates.test-d.ts diff --git a/packages/docs/src/app/playground/_demos/subscribeToQueryUpdates/page.tsx b/packages/docs/src/app/playground/_demos/subscribeToQueryUpdates/page.tsx deleted file mode 100644 index 88e045549..000000000 --- a/packages/docs/src/app/playground/_demos/subscribeToQueryUpdates/page.tsx +++ /dev/null @@ -1,36 +0,0 @@ -'use client' - -import { parseAsInteger, subscribeToQueryUpdates, useQueryState } from 'nuqs' -import React from 'react' - -export default function BuilderPatternDemoPage() { - const [counter, setCounter] = useQueryState( - 'counter', - parseAsInteger.withDefault(0) - ) - - React.useEffect(() => { - const off = subscribeToQueryUpdates(({ search }) => - console.log(search.toString()) - ) - return off - }, []) - - return ( - <> -

Subscribing to query updates

- - - -

{counter}

-

- Check the console -

-

- - Source on GitHub - -

- - ) -} diff --git a/packages/e2e/src/pages/pages/useQueryState/index.tsx b/packages/e2e/src/pages/pages/useQueryState/index.tsx index 3142c2fcf..ecb17f993 100644 --- a/packages/e2e/src/pages/pages/useQueryState/index.tsx +++ b/packages/e2e/src/pages/pages/useQueryState/index.tsx @@ -1,7 +1,13 @@ import { GetServerSideProps } from 'next' import Link from 'next/link' import { usePathname } from 'next/navigation' -import { parseAsString, queryTypes, useQueryState } from 'nuqs' +import { + parseAsBoolean, + parseAsFloat, + parseAsInteger, + parseAsString, + useQueryState +} from 'nuqs' import { HydrationMarker } from '../../../components/hydration-marker' export const getServerSideProps = (async ctx => { @@ -18,12 +24,12 @@ export const getServerSideProps = (async ctx => { const IntegrationPage = () => { const [string, setString] = useQueryState('string') - const [int, setInt] = useQueryState('int', queryTypes.integer) - const [float, setFloat] = useQueryState('float', queryTypes.float) - const [bool, setBool] = useQueryState('bool', queryTypes.boolean) + const [int, setInt] = useQueryState('int', parseAsInteger) + const [float, setFloat] = useQueryState('float', parseAsFloat) + const [bool, setBool] = useQueryState('bool', parseAsBoolean) const [text, setText] = useQueryState( 'text', - queryTypes.string.withDefault('Hello, world!') + parseAsString.withDefault('Hello, world!') ) const pathname = usePathname() return ( diff --git a/packages/e2e/src/pages/pages/useQueryStates/index.tsx b/packages/e2e/src/pages/pages/useQueryStates/index.tsx index 990c6d23c..17d2357d2 100644 --- a/packages/e2e/src/pages/pages/useQueryStates/index.tsx +++ b/packages/e2e/src/pages/pages/useQueryStates/index.tsx @@ -1,13 +1,19 @@ import Link from 'next/link' -import { queryTypes, useQueryStates } from 'nuqs' +import { + parseAsBoolean, + parseAsFloat, + parseAsInteger, + parseAsString, + useQueryStates +} from 'nuqs' import { HydrationMarker } from '../../../components/hydration-marker' const IntegrationPage = () => { const [state, setState] = useQueryStates({ - string: queryTypes.string, - int: queryTypes.integer, - float: queryTypes.float, - bool: queryTypes.boolean + string: parseAsString, + int: parseAsInteger, + float: parseAsFloat, + bool: parseAsBoolean }) return ( <> diff --git a/packages/nuqs/src/deprecated.ts b/packages/nuqs/src/deprecated.ts deleted file mode 100644 index d9583c695..000000000 --- a/packages/nuqs/src/deprecated.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { Parser, ParserBuilder } from './parsers' -import { - parseAsArrayOf, - parseAsBoolean, - parseAsFloat, - parseAsInteger, - parseAsIsoDateTime, - parseAsJson, - parseAsString, - parseAsStringEnum, - parseAsTimestamp -} from './parsers' - -/** - * @deprecated renamed to Parser - */ -export type Serializers = Parser - -/** - * @deprecated renamed to ParserBuilder. - * You should probably use `createParser` instead. - */ -export type SerializersWithDefaultFactory = ParserBuilder - -/** - * @deprecated use individual `parseAsXyz` imports instead. - */ -export const queryTypes = { - /** - * @deprecated use `parseAsString` instead. - */ - string: parseAsString, - /** - * @deprecated use `parseAsInteger` instead. - */ - integer: parseAsInteger, - /** - * @deprecated use `parseAsFloat` instead. - */ - float: parseAsFloat, - /** - * @deprecated use `parseAsBoolean` instead. - */ - boolean: parseAsBoolean, - /** - * @deprecated use `parseAsTimestamp` instead. - */ - timestamp: parseAsTimestamp, - /** - * @deprecated use `parseAsIsoDateTime` instead. - */ - isoDateTime: parseAsIsoDateTime, - /** - * @deprecated use `parseAsStringEnum` instead. - */ - stringEnum: parseAsStringEnum, - /** - * @deprecated use `parseAsJson` instead. - */ - json: parseAsJson, - /** - * @deprecated use `parseAsArrayOf` instead. - */ - array: parseAsArrayOf -} as const - -/** - * @deprecated use individual `parseAsXyz` imports instead - */ -export type QueryTypeMap = typeof queryTypes diff --git a/packages/nuqs/src/index.ts b/packages/nuqs/src/index.ts index 1ee6ee933..7a428df1c 100644 --- a/packages/nuqs/src/index.ts +++ b/packages/nuqs/src/index.ts @@ -1,10 +1,8 @@ 'use client' export type { HistoryOptions, Options } from './defs' -export * from './deprecated' export * from './parsers' export { createSerializer } from './serializer' -export { subscribeToQueryUpdates } from './sync' export type { QueryUpdateNotificationArgs, QueryUpdateSource } from './sync' export * from './useQueryState' export * from './useQueryStates' diff --git a/packages/nuqs/src/sync.ts b/packages/nuqs/src/sync.ts index fb0a476d7..99bf74d29 100644 --- a/packages/nuqs/src/sync.ts +++ b/packages/nuqs/src/sync.ts @@ -31,21 +31,6 @@ declare global { } } -/** - * @deprecated Since Next.js introduced shallow routing in 14.0.3, this - * method is no longer needed as you can use `useSearchParams`, which will - * react to changes in the URL when the `windowHistorySupport` experimental flag - * is set. - * This method will be removed in `nuqs@2.0.0`, when Next.js - * decides to land the `windowHistorySupport` flag in GA. - */ -export function subscribeToQueryUpdates( - callback: (args: QueryUpdateNotificationArgs) => void -) { - emitter.on(NOTIFY_EVENT_KEY, callback) - return () => emitter.off(NOTIFY_EVENT_KEY, callback) -} - if (typeof history === 'object') { patchHistory() } diff --git a/packages/nuqs/src/tests/compat/useQueryState.test-d.ts b/packages/nuqs/src/tests/compat/useQueryState.test-d.ts deleted file mode 100644 index 30d93dfa5..000000000 --- a/packages/nuqs/src/tests/compat/useQueryState.test-d.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { expectError, expectNotAssignable, expectType } from 'tsd' -import { queryTypes, useQueryState } from '../../../dist' - -// By default, queries have a `string` state, nullable (when no query parameter is present) -{ - const [state, setState] = useQueryState('foo') - expectType(state) - setState('bar') - setState(old => old?.toUpperCase() ?? null) - const search = await setState('bar') - expectType(search) -} - -// Accept only a single `history` option -{ - const [state, setState] = useQueryState('foo', { history: 'push' }) - expectType(state) - setState('bar') - setState(old => old?.toUpperCase() ?? null) - const search = await setState('bar') - expectType(search) -} - -// Supported query types -{ - const [state] = useQueryState('string', queryTypes.string) - expectType(state) -} -{ - const [state] = useQueryState('integer', queryTypes.integer) - expectType(state) -} -{ - const [state] = useQueryState('float', queryTypes.float) - expectType(state) -} -{ - const [state] = useQueryState('boolean', queryTypes.boolean) - expectType(state) -} -{ - const [state] = useQueryState('boolean', queryTypes.timestamp) - expectType(state) -} -{ - const [state] = useQueryState('boolean', queryTypes.isoDateTime) - expectType(state) -} - -// With default values, state is no longer nullable -{ - const [state] = useQueryState('string', queryTypes.string.withDefault('foo')) - expectType(state) - expectNotAssignable(state) -} -{ - const [state] = useQueryState('integer', queryTypes.integer.withDefault(0)) - expectType(state) - expectNotAssignable(state) -} -{ - const [state] = useQueryState('float', queryTypes.float.withDefault(0)) - expectType(state) - expectNotAssignable(state) -} -{ - const [state] = useQueryState( - 'boolean', - queryTypes.boolean.withDefault(false) - ) - expectType(state) - expectNotAssignable(state) -} -{ - const [state] = useQueryState( - 'boolean', - queryTypes.timestamp.withDefault(new Date()) - ) - expectType(state) - expectNotAssignable(state) -} -{ - const [state] = useQueryState( - 'boolean', - queryTypes.isoDateTime.withDefault(new Date()) - ) - expectType(state) - expectNotAssignable(state) -} - -// Default value can be spread in: -{ - const [state] = useQueryState('string', { - ...queryTypes.string, - defaultValue: 'foo' - }) - expectType(state) - expectNotAssignable(state) -} -{ - const [state] = useQueryState('integer', { - ...queryTypes.integer, - defaultValue: 0 - }) - expectType(state) - expectNotAssignable(state) -} -{ - const [state] = useQueryState('float', { - ...queryTypes.float, - defaultValue: 0 - }) - expectType(state) - expectNotAssignable(state) -} -{ - const [state] = useQueryState('boolean', { - ...queryTypes.boolean, - defaultValue: false - }) - expectType(state) - expectNotAssignable(state) -} -{ - const [state] = useQueryState('boolean', { - ...queryTypes.timestamp, - defaultValue: new Date() - }) - expectType(state) - expectNotAssignable(state) -} -{ - const [state] = useQueryState('boolean', { - ...queryTypes.isoDateTime, - defaultValue: new Date() - }) - expectType(state) - expectNotAssignable(state) -} - -// Custom serializers -- -{ - const [hex] = useQueryState('foo', { - parse: input => parseInt(input, 16) - }) - expectType(hex) -} -{ - const [num] = useQueryState('foo', { - parse: parseInt, - serialize: value => value.toString(16) - }) - expectType(num) - - const [hex] = useQueryState('foo', { - parse: (input: string) => parseInt(input, 16), - serialize: value => value.toString(16) - }) - expectType(hex) - - const [len] = useQueryState('length', { - parse: (input: string) => input.length, - serialize: value => Array.from({ length: value }, () => '•').join('') - }) - expectType(len) -} -{ - const [hex] = useQueryState('foo', { - parse: input => parseInt(input, 16), - serialize: value => value.toString(16), - defaultValue: 0x2a - }) - expectType(hex) - expectNotAssignable(hex) -} -{ - const [hex] = useQueryState('foo', { - parse: input => parseInt(input, 16), - defaultValue: 0x2a - }) - expectType(hex) - expectNotAssignable(hex) -} - -// Allow setting `null` to clear the query -{ - const [, set] = useQueryState('foo') - set(null) - set(old => { - expectType(old) - return null - }) -} -{ - const [, set] = useQueryState('foo', queryTypes.integer) - set(null) - set(old => { - expectType(old) - return null - }) -} -{ - const [, set] = useQueryState('foo', queryTypes.float.withDefault(0.2)) - set(null) - set(old => { - expectType(old) // We know it's not null here - return null // But we can return null to clear the query - }) -} - -// Allow specifying just the default value for a string type -{ - useQueryState('foo', { - defaultValue: 'bar' - }) - const [val, set] = useQueryState('foo', { - defaultValue: 'bar', - history: 'push' - }) - expectType(val) - set(null) - set(old => { - expectType(old) // We know it's not null here - return null // But we can return null to clear the query - }) - expectError(() => { - useQueryState('foo', { - defaultValue: 2 // not allowed for other types - }) - }) -} - -// Expect errors on misuse -{ - expectError(() => { - useQueryState('foo', { - parse: (str: string) => str.length, - serialize: value => value.toUpperCase() - }) - }) -} -{ - expectError(() => { - // parser not specified, defaults to string, should clash with explicit hook type - useQueryState('foo') - }) -} - -// Set state to undefined -{ - const [, setFoo] = useQueryState('foo') - const [, setBar] = useQueryState('bar', queryTypes.string.withDefault('egg')) - expectError(() => setFoo(undefined)) - expectError(() => setBar(undefined)) - expectError(() => setFoo(() => undefined)) - expectError(() => setBar(() => undefined)) -} diff --git a/packages/nuqs/src/tests/compat/useQueryStates.test-d.ts b/packages/nuqs/src/tests/compat/useQueryStates.test-d.ts deleted file mode 100644 index e9c6c8ada..000000000 --- a/packages/nuqs/src/tests/compat/useQueryStates.test-d.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { expectNotAssignable, expectType } from 'tsd' -import { queryTypes, useQueryStates } from '../../../dist' - -{ - const [states, setStates] = useQueryStates( - { - a: queryTypes.string, - b: queryTypes.integer, - c: queryTypes.float, - d: queryTypes.boolean - }, - { - history: 'push' - } - ) - expectType<{ - a: string | null - b: number | null - c: number | null - d: boolean | null - }>(states) - setStates({ - a: 'foo', - c: 3.14 - }) - setStates(old => ({ - ...old, - d: !old.d - })) -} - -// With default values, state is no longer nullable -{ - const [states, setStates] = useQueryStates({ - hasDefault: queryTypes.string.withDefault('foo'), - doesNot: queryTypes.isoDateTime - }) - expectType<{ - hasDefault: string - doesNot: Date | null - }>(states) - expectNotAssignable(states.hasDefault) - states.doesNot = null - // `null` should always be accepted as setStates - setStates({ - hasDefault: null, - doesNot: null - }) - setStates(() => ({ - hasDefault: null, - doesNot: null - })) - setStates(null) -} - -// Custom parsers -{ - const [states] = useQueryStates({ - hex: { - parse: input => parseInt(input, 16), - serialize: (value: number) => value.toString(16) - }, - bin: { - parse: input => Buffer.from(input), - defaultValue: Buffer.from('') - } - }) - expectType<{ - hex: number | null - bin: Buffer - }>(states) -} diff --git a/packages/nuqs/src/useQueryState.ts b/packages/nuqs/src/useQueryState.ts index d13c5eef3..b36a1a75d 100644 --- a/packages/nuqs/src/useQueryState.ts +++ b/packages/nuqs/src/useQueryState.ts @@ -49,7 +49,7 @@ export type UseQueryStateReturn = [ * ```ts * const [count, setCount] = useQueryState( * 'count', - * queryTypes.integer.defaultValue(0) + * parseAsInteger.defaultValue(0) * ) * * const increment = () => setCount(oldCount => oldCount + 1) @@ -104,11 +104,11 @@ export function useQueryState( * If the query is missing in the URL, the state will be `null`. * * Note: by default the state type is a `string`. To use different types, - * check out the `queryTypes` helpers: + * check out the `parseAsXYZ` helpers: * ```ts * const [date, setDate] = useQueryState( * 'date', - * queryTypes.isoDateTime.withDefault(new Date('2021-01-01')) + * parseAsIsoDateTime.withDefault(new Date('2021-01-01')) * ) * * const setToNow = () => setDate(new Date()) @@ -130,11 +130,11 @@ export function useQueryState( * If the query is missing in the URL, the state will be `null`. * * Note: by default the state type is a `string`. To use different types, - * check out the `queryTypes` helpers: + * check out the `parseAsXYZ` helpers: * ```ts * const [date, setDate] = useQueryState( * 'date', - * queryTypes.isoDateTime.withDefault(new Date('2021-01-01')) + * parseAsIsoDateTime.withDefault(new Date('2021-01-01')) * ) * * const setToNow = () => setDate(new Date()) @@ -173,7 +173,7 @@ export function useQueryState( * * const [count, setCount] = useQueryState( * 'count', - * queryTypes.integer.defaultValue(0) + * parseAsInteger.defaultValue(0) * ) * * const increment = () => setCount(oldCount => oldCount + 1) @@ -184,7 +184,7 @@ export function useQueryState( * * const [date, setDate] = useQueryState( * 'date', - * queryTypes.isoDateTime.withDefault(new Date('2021-01-01')) + * parseAsIsoDateTime.withDefault(new Date('2021-01-01')) * ) * * const setToNow = () => setDate(new Date()) diff --git a/packages/nuqs/src/useQueryStates.ts b/packages/nuqs/src/useQueryStates.ts index fce1c3953..316bc976c 100644 --- a/packages/nuqs/src/useQueryStates.ts +++ b/packages/nuqs/src/useQueryStates.ts @@ -54,7 +54,7 @@ export type UseQueryStatesReturn = [ * * @param keys - An object describing the keys to synchronise and how to * serialise and parse them. - * Use `queryTypes.(string|integer|float)` for quick shorthands. + * Use `parseAs(String|Integer|Float|...)` for quick shorthands. * @param options - Optional history mode, shallow routing and scroll restoration options. */ export function useQueryStates( From 0fa74ff3b9a23834b3330a2aa67e96a44fd4e78f Mon Sep 17 00:00:00 2001 From: Francois Best Date: Mon, 15 Jan 2024 23:05:17 +0100 Subject: [PATCH 03/34] chore: Drop mirrorring to next-usequerystate BREAKING CHANGE: package is now only updating on the `nuqs` name. The debugging printouts no longer check for next-usequerystate, only nuqs. --- .github/ISSUE_TEMPLATE/bug_report.md | 2 +- .github/workflows/ci-cd.yml | 6 ---- packages/docs/content/docs/installation.mdx | 2 +- .../docs/src/app/(pages)/_landing/demo.tsx | 1 - .../docs/src/app/playground/debug-control.tsx | 6 ++-- packages/nuqs/scripts/mirror.sh | 33 ------------------- 6 files changed, 4 insertions(+), 46 deletions(-) delete mode 100755 packages/nuqs/scripts/mirror.sh diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index fcf491a3b..66d0c1395 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -29,7 +29,7 @@ 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 +- ✅/❌ The experimental `windowHistorySupport` flag in your Next.js config _(only relevant for Next.js 14.0.3 or 14.0.4)_ ## Description diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 148e335e0..5088861e8 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -125,11 +125,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/packages/docs/content/docs/installation.mdx b/packages/docs/content/docs/installation.mdx index 1a4003bdb..70a70ffd9 100644 --- a/packages/docs/content/docs/installation.mdx +++ b/packages/docs/content/docs/installation.mdx @@ -34,7 +34,7 @@ bun add nuqs 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 +The `nuqs` name was introduced in 1.14.0, and `next-usequerystate` mirrored 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. diff --git a/packages/docs/src/app/(pages)/_landing/demo.tsx b/packages/docs/src/app/(pages)/_landing/demo.tsx index cfe879393..36ebcd505 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 ( <> { setChecked(c => { const checked = !c if (typeof localStorage !== 'undefined') { if (checked) { - localStorage.setItem('debug', 'next-usequerystate') + localStorage.setItem('debug', 'nuqs') } else { localStorage.removeItem('debug') } diff --git a/packages/nuqs/scripts/mirror.sh b/packages/nuqs/scripts/mirror.sh deleted file mode 100755 index db8e3113b..000000000 --- a/packages/nuqs/scripts/mirror.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env bash - -set -e - -# Place ourselves in the package directory -cd "$(dirname "$0")/.." - -# Abort if the version was not bumped by semantic release -version=$(cat package.json | jq -r '.version') -if [[ $version == "0.0.0-semantically-released" ]]; then - echo "Aborting publish to next-usequerystate: the version was not bumped by semantic release" - exit 0 -fi - -# Get the release channel (dist tag) from the version name. -# If version contains a hyphen, it's a prerelease (beta), -# otherwise it's a stable release (latest) -distTag="latest" -if [[ $version == *"-"* ]]; then - distTag="beta" -fi - -echo "Publishing next-usequerystate@${version} to the ${distTag} channel" - -# Login to the npm registry -echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc - -# Rename & publish the package -pnpm pkg set name=next-usequerystate -pnpm publish --no-git-checks --tag ${distTag} - -# Cleanup -rm -f .npmrc From bd1cf58e5420d3cb85cc4287d698349a1cf26caf Mon Sep 17 00:00:00 2001 From: Francois Best Date: Mon, 15 Jan 2024 23:13:22 +0100 Subject: [PATCH 04/34] chore: Rename `nuqs/parsers` to `nuqs/server` BREAKING CHANGE: export path has been renamed. Contents are identical. Since the `/parsers` export contained the server cache, this name makes better sense and helps outline the client/server nature of features in nuqs. --- packages/nuqs/package.json | 7 +------ packages/nuqs/parsers.d.ts | 7 ------- packages/nuqs/src/index.parsers.ts | 7 ------- packages/nuqs/src/tests/cache.test-d.ts | 2 +- 4 files changed, 2 insertions(+), 21 deletions(-) delete mode 100644 packages/nuqs/parsers.d.ts delete mode 100644 packages/nuqs/src/index.parsers.ts diff --git a/packages/nuqs/package.json b/packages/nuqs/package.json index 4c346393a..a291b325b 100644 --- a/packages/nuqs/package.json +++ b/packages/nuqs/package.json @@ -42,12 +42,7 @@ }, "./server": { "types": "./dist/server.d.ts", - "import": "./dist/server.js", - "require": "./dist/server.cjs" - }, - "./parsers": { - "types": "./dist/parsers.d.ts", - "import": "./dist/parsers.js" + "import": "./dist/server.js" } }, "scripts": { diff --git a/packages/nuqs/parsers.d.ts b/packages/nuqs/parsers.d.ts deleted file mode 100644 index 6e0e1b447..000000000 --- a/packages/nuqs/parsers.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -// This file is needed for projects that have `moduleResolution` set to `node` -// in their tsconfig.json to be able to `import {} from 'nuqs/parsers'`. -// Other module resolutions strategies will look for the `exports` in `package.json`, -// but with `node`, TypeScript will look for a .d.ts file with that name at the -// root of the package. - -export * from './dist/parsers' diff --git a/packages/nuqs/src/index.parsers.ts b/packages/nuqs/src/index.parsers.ts deleted file mode 100644 index 47f28385f..000000000 --- a/packages/nuqs/src/index.parsers.ts +++ /dev/null @@ -1,7 +0,0 @@ -console.warn( - 'Please update your imports from `nuqs/parsers` to `nuqs/server`. Importing from `nuqs/parsers` is deprecated, and will be removed in v2.0.0.' -) - -export * from './cache' -export * from './parsers' -export { createSerializer } from './serializer' diff --git a/packages/nuqs/src/tests/cache.test-d.ts b/packages/nuqs/src/tests/cache.test-d.ts index 2812f8bfb..49c3459f4 100644 --- a/packages/nuqs/src/tests/cache.test-d.ts +++ b/packages/nuqs/src/tests/cache.test-d.ts @@ -4,7 +4,7 @@ import { parseAsBoolean, parseAsInteger, parseAsString -} from '../../dist/parsers' +} from '../../dist/server' { const cache = createSearchParamsCache({ From 1795d2e675229064965a1f37fed0cce7051cd9bb Mon Sep 17 00:00:00 2001 From: Francois Best Date: Mon, 15 Jan 2024 23:23:51 +0100 Subject: [PATCH 05/34] chore: Fix path to server entrypoint --- packages/nuqs/tsup.config.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/nuqs/tsup.config.ts b/packages/nuqs/tsup.config.ts index f48368a0f..1dd6c87c5 100644 --- a/packages/nuqs/tsup.config.ts +++ b/packages/nuqs/tsup.config.ts @@ -3,7 +3,6 @@ import { defineConfig } from 'tsup' export default defineConfig({ entry: { index: 'src/index.ts', - parsers: 'src/index.parsers.ts', server: 'src/index.server.ts' }, format: ['esm'], From c920e080cce7eded1224b6aa23917f79f776170f Mon Sep 17 00:00:00 2001 From: Francois Best Date: Mon, 15 Jan 2024 23:26:01 +0100 Subject: [PATCH 06/34] chore: Add peer dep on React (for `cache`) --- packages/nuqs/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/nuqs/package.json b/packages/nuqs/package.json index a291b325b..bef55a855 100644 --- a/packages/nuqs/package.json +++ b/packages/nuqs/package.json @@ -56,7 +56,8 @@ "prepack": "./scripts/prepack.sh" }, "peerDependencies": { - "next": ">=13.4 <14.0.2 || ^14.0.3" + "next": ">=13.4 <14.0.2 || ^14.0.3", + "react": "^18.2.0" }, "dependencies": { "mitt": "^3.0.1" From c718b0e888dd7a21b8c22eb9ea10dfa1c519471d Mon Sep 17 00:00:00 2001 From: Francois Best Date: Tue, 16 Jan 2024 04:52:31 +0100 Subject: [PATCH 07/34] doc: Add v2 migration docs --- packages/docs/content/docs/migrations/v2.mdx | 66 ++++++++++++++++++++ packages/docs/next.config.mjs | 3 +- packages/docs/package.json | 11 ++++ pnpm-lock.yaml | 22 +++++++ 4 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 packages/docs/content/docs/migrations/v2.mdx diff --git a/packages/docs/content/docs/migrations/v2.mdx b/packages/docs/content/docs/migrations/v2.mdx new file mode 100644 index 000000000..565059d7c --- /dev/null +++ b/packages/docs/content/docs/migrations/v2.mdx @@ -0,0 +1,66 @@ +--- +title: Migration guide to v2 +description: How to update your code to use nuqs@2.0.0 +--- + +## 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 from 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 + +Replace with `parseAsXYZ` to match, for better tree-shakeability: + +```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.0.5 makes `useSearchParams` reactive to shallow search params updates, which makes this internal helper function redundant. See #425 for context. + +## Renamed `nuqs/parsers` to `nuqs/server` + +When introducing the server cache in #387, the dedicated export for parsers was reused as it didn't include the `"use client"` 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` variable. In `nuqs@2.0.0` it 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') +} +``` diff --git a/packages/docs/next.config.mjs b/packages/docs/next.config.mjs index 0fdf8ae7b..1ec53af12 100644 --- a/packages/docs/next.config.mjs +++ b/packages/docs/next.config.mjs @@ -1,11 +1,12 @@ import { withSentryConfig } from '@sentry/nextjs' import createNextDocsMDX from 'next-docs-mdx/config' +import remarkGitHub from 'remark-github' import remarkMdxImages from 'remark-mdx-images' import remarkSmartypants from 'remark-smartypants' const withFumaMDX = createNextDocsMDX({ mdxOptions: { - remarkPlugins: [remarkMdxImages, remarkSmartypants] + remarkPlugins: [remarkMdxImages, remarkGitHub, remarkSmartypants] } }) diff --git a/packages/docs/package.json b/packages/docs/package.json index b8df4f4c1..6c6e650c9 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", @@ -37,6 +47,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "recharts": "^2.12.7", + "remark-github": "^12.0.0", "remark-smartypants": "^2.1.0", "res": "workspace:*", "semver": "^7.6.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 06dea00fa..b6d0889a9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -119,6 +119,9 @@ importers: recharts: specifier: ^2.12.7 version: 2.12.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + remark-github: + specifier: ^12.0.0 + version: 12.0.0 remark-smartypants: specifier: ^2.1.0 version: 2.1.0 @@ -4729,6 +4732,9 @@ packages: remark-gfm@4.0.0: resolution: {integrity: sha512-U92vJgBPkbw4Zfu/IiW2oTZLSL3Zpv+uI7My2eq8JxKgqraFdU8YUGicEJCEgSbeaG+QDFqIcwwfMTOEelPxuA==} + remark-github@12.0.0: + resolution: {integrity: sha512-ByefQKFN184LeiGRCabfl7zUJsdlMYWEhiLX1gpmQ11yFg6xSuOTW7LVCv0oc1x+YvUMJW23NU36sJX2RWGgvg==} + remark-mdx-images@3.0.0: resolution: {integrity: sha512-EKpqw11XUprx/kFQPEkH3GdPEqI63Wcq5GT120N2hGlKCSwOM09NtL1i3G9HQ4TBIR2aF471HF5YStME5ukbJw==} deprecated: Use rehype-mdx-import-media instead @@ -5252,6 +5258,9 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + to-vfile@8.0.0: + resolution: {integrity: sha512-IcmH1xB5576MJc9qcfEC/m/nQCFt3fzMHz45sSlgJyTWjRbKW1HAkJpuf3DgE57YzIlZcwcBZA5ENQbBo4aLkg==} + tough-cookie@4.1.4: resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} engines: {node: '>=6'} @@ -10673,6 +10682,15 @@ snapshots: transitivePeerDependencies: - supports-color + remark-github@12.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-find-and-replace: 3.0.1 + mdast-util-to-string: 4.0.0 + to-vfile: 8.0.0 + unist-util-visit: 5.0.0 + vfile: 6.0.3 + remark-mdx-images@3.0.0: dependencies: '@types/mdast': 4.0.4 @@ -11301,6 +11319,10 @@ snapshots: dependencies: is-number: 7.0.0 + to-vfile@8.0.0: + dependencies: + vfile: 6.0.3 + tough-cookie@4.1.4: dependencies: psl: 1.9.0 From 541ebef5f52da1f25eff908732b6a208731345aa Mon Sep 17 00:00:00 2001 From: Francois Best Date: Tue, 16 Jan 2024 04:57:23 +0100 Subject: [PATCH 08/34] doc: Formatting --- packages/docs/content/docs/migrations/v2.mdx | 24 +++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/docs/content/docs/migrations/v2.mdx b/packages/docs/content/docs/migrations/v2.mdx index 565059d7c..a65711437 100644 --- a/packages/docs/content/docs/migrations/v2.mdx +++ b/packages/docs/content/docs/migrations/v2.mdx @@ -24,11 +24,15 @@ 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`. +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 -Replace with `parseAsXYZ` to match, for better tree-shakeability: +The `queryTypes` object has been removed in favor of individual parser exports, +for better tree-shaking. + +Replace with `parseAsXYZ` to match: ```diff - import { queryTypes } from 'nuqs' @@ -42,13 +46,17 @@ Replace with `parseAsXYZ` to match, for better tree-shakeability: ### `subscribeToQueryUpdates` -Next.js 14.0.5 makes `useSearchParams` reactive to shallow search params updates, which makes this internal helper function redundant. See #425 for context. +Next.js 14.0.5 makes `useSearchParams` reactive to shallow search params updates, +which makes this internal helper function redundant. See #425 for context. ## Renamed `nuqs/parsers` to `nuqs/server` -When introducing the server cache in #387, the dedicated export for parsers was reused as it didn't include the `"use client"` 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. +When introducing the server cache in #387, the dedicated export for parsers was +reused as it didn't include the `"use client"` 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: +Find and replace all occurrences of `nuqs/parsers` to `nuqs/server` in your code: ```diff - import { parseAsInteger, createSearchParamsCache } from 'nuqs/parsers' @@ -57,7 +65,11 @@ Find and replace all occurrences of `'nuqs/parsers'` to `'nuqs/server'` in your ## 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` variable. In `nuqs@2.0.0` it 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: +After the rename to `nuqs`, the debugging printout detection logic handled either +`next-usequerystate` or `nuqs` being present in the `localStorage.debug` 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) { From fadd38cc4c753fd6a1d7c6b1ff541a2302d671bf Mon Sep 17 00:00:00 2001 From: Francois Best Date: Tue, 16 Jan 2024 05:02:15 +0100 Subject: [PATCH 09/34] doc: Fix ESM/CJS error --- packages/docs/content/docs/migrations/v2.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/docs/content/docs/migrations/v2.mdx b/packages/docs/content/docs/migrations/v2.mdx index a65711437..77eeb8b1f 100644 --- a/packages/docs/content/docs/migrations/v2.mdx +++ b/packages/docs/content/docs/migrations/v2.mdx @@ -12,7 +12,7 @@ Next.js supports ESM in app code since version 12, but if you are bundling you'll run into import issues: ```txt -[ERR_REQUIRE_ESM]: require() of ES Module from not supported +[ERR_REQUIRE_ESM]: require() of ES Module not supported ``` If converting your library to ESM is not possible, your main option is to From 8f2dd3083bc8042eb8282ad44dba60e36da44c0e Mon Sep 17 00:00:00 2001 From: Francois Best Date: Wed, 17 Jan 2024 21:42:12 +0100 Subject: [PATCH 10/34] chore: Drop unnecessary ESM specifier for dev --- packages/nuqs/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nuqs/package.json b/packages/nuqs/package.json index bef55a855..04bafaa17 100644 --- a/packages/nuqs/package.json +++ b/packages/nuqs/package.json @@ -46,7 +46,7 @@ } }, "scripts": { - "dev": "tsup --format esm --watch --external=react", + "dev": "tsup --watch --external=react", "build": "tsup --clean --external=react", "postbuild": "size-limit --json > size.json", "test": "run-p test:*", From 51a1ae701bd654ae0f4f6134222a23527f3b1f5b Mon Sep 17 00:00:00 2001 From: Francois Best Date: Wed, 17 Jan 2024 23:39:02 +0100 Subject: [PATCH 11/34] chore: Update list of supported versions BREAKING CHANGE: Drop support for next@14.0.3 Due to a bug in the implementation of shallow routing (WHS), 14.0.3 required a special case for syncing against external navigation. In `nuqs@2.x`, we're cleaning this up and requiring a version of Next.js with bug-free support for shallow routing (with or without experimental WHS in 14.0.4, and with stabilised WHS in 14.0.5 onwards). --- .github/workflows/ci-cd.yml | 7 +++---- packages/docs/content/docs/migrations/v2.mdx | 8 ++++++++ packages/nuqs/package.json | 2 +- packages/nuqs/src/useQueryState.ts | 17 ----------------- packages/nuqs/src/useQueryStates.ts | 20 -------------------- 5 files changed, 12 insertions(+), 42 deletions(-) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 5088861e8..319d7a9f8 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -24,16 +24,15 @@ jobs: base-path: [false, '/base'] window-history-support: [false] next-version: - - '13.4' + - '13.4' # App router in GA - '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.3 is dropped due to a bug requiring WHS + syncing against uSP, + # which creates other problems under other versions of Next.js - '14.0.4' - latest # Current latest is 14.1.0 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 diff --git a/packages/docs/content/docs/migrations/v2.mdx b/packages/docs/content/docs/migrations/v2.mdx index 77eeb8b1f..381d4ce0c 100644 --- a/packages/docs/content/docs/migrations/v2.mdx +++ b/packages/docs/content/docs/migrations/v2.mdx @@ -3,6 +3,14 @@ title: Migration guide to v2 description: How to update your code to use nuqs@2.0.0 --- +## Dropped support for `next@14.0.3` + +It may seem weird to drop support for a single patch version, and keep it for +older versions, but this is due to a bug in shallow routing in Next.js 14.0.3 +that was fixed in 14.0.4, and that became hard to work around without ugly hacks. + +See #423 for context and a table of supported versions. + ## ESM only `nuqs@2.0.0` is now an [ESM-only](https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c) diff --git a/packages/nuqs/package.json b/packages/nuqs/package.json index 04bafaa17..600eec1c4 100644 --- a/packages/nuqs/package.json +++ b/packages/nuqs/package.json @@ -56,7 +56,7 @@ "prepack": "./scripts/prepack.sh" }, "peerDependencies": { - "next": ">=13.4 <14.0.2 || ^14.0.3", + "next": ">=13.4 <14.0.2 || ^14.0.4", "react": "^18.2.0" }, "dependencies": { diff --git a/packages/nuqs/src/useQueryState.ts b/packages/nuqs/src/useQueryState.ts index b36a1a75d..668358f00 100644 --- a/packages/nuqs/src/useQueryState.ts +++ b/packages/nuqs/src/useQueryState.ts @@ -241,23 +241,6 @@ export function useQueryState( initialSearchParams?.get(key) ?? null ) - React.useEffect(() => { - // This will be removed in v2 which will drop support for - // partially-functional shallow routing (14.0.2 and 14.0.3) - if (window.next?.version !== '14.0.3') { - return - } - const query = initialSearchParams.get(key) ?? null - if (query === queryRef.current) { - return - } - const state = query === null ? null : safeParse(parse, query, key) - debug('[nuqs `%s`] syncFromUseSearchParams %O', key, state) - stateRef.current = state - queryRef.current = query - setInternalState(state) - }, [initialSearchParams?.get(key), key]) - // Sync all hooks together & with external URL changes React.useInsertionEffect(() => { function updateInternalState({ state, query }: CrossHookSyncPayload) { diff --git a/packages/nuqs/src/useQueryStates.ts b/packages/nuqs/src/useQueryStates.ts index 316bc976c..82cef0914 100644 --- a/packages/nuqs/src/useQueryStates.ts +++ b/packages/nuqs/src/useQueryStates.ts @@ -88,26 +88,6 @@ export function useQueryStates( initialSearchParams ) - React.useEffect(() => { - // This will be removed in v2 which will drop support for - // partially-functional shallow routing (14.0.2 and 14.0.3) - if (window.next?.version !== '14.0.3') { - return - } - const state = parseMap( - keyMap, - initialSearchParams, - queryRef.current, - stateRef.current - ) - setInternalState(state) - }, [ - Object.keys(keyMap) - .map(key => initialSearchParams?.get(key)) - .join('&'), - keys - ]) - // Sync all hooks together & with external URL changes React.useInsertionEffect(() => { function updateInternalState(state: V) { From 0996b914ee90233c2d37d0419da62363b272efbe Mon Sep 17 00:00:00 2001 From: Francois Best Date: Wed, 17 Jan 2024 23:41:01 +0100 Subject: [PATCH 12/34] doc: Wording --- packages/docs/content/docs/migrations/v2.mdx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/docs/content/docs/migrations/v2.mdx b/packages/docs/content/docs/migrations/v2.mdx index 381d4ce0c..f41f6ebd3 100644 --- a/packages/docs/content/docs/migrations/v2.mdx +++ b/packages/docs/content/docs/migrations/v2.mdx @@ -7,7 +7,8 @@ description: How to update your code to use nuqs@2.0.0 It may seem weird to drop support for a single patch version, and keep it for older versions, but this is due to a bug in shallow routing in Next.js 14.0.3 -that was fixed in 14.0.4, and that became hard to work around without ugly hacks. +that was fixed in 14.0.4, and that became hard to work around without ugly hacks +as Next.js releases evolved. See #423 for context and a table of supported versions. From 87b72572b28e964310c47bbe47ee7a48de2a6d4f Mon Sep 17 00:00:00 2001 From: Francois Best Date: Thu, 18 Jan 2024 22:36:35 +0100 Subject: [PATCH 13/34] chore: Next GA is 14.1.0 Source: https://github.com/vercel/next.js/discussions/48110#discussioncomment-8174322 --- packages/docs/content/docs/migrations/v2.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/docs/content/docs/migrations/v2.mdx b/packages/docs/content/docs/migrations/v2.mdx index f41f6ebd3..5965e6de6 100644 --- a/packages/docs/content/docs/migrations/v2.mdx +++ b/packages/docs/content/docs/migrations/v2.mdx @@ -55,7 +55,7 @@ Replace with `parseAsXYZ` to match: ### `subscribeToQueryUpdates` -Next.js 14.0.5 makes `useSearchParams` reactive to shallow search params updates, +Next.js 14.1.0 makes `useSearchParams` reactive to shallow search params updates, which makes this internal helper function redundant. See #425 for context. ## Renamed `nuqs/parsers` to `nuqs/server` From cfcea0dd780bf51f7aee64bf866817c402c79858 Mon Sep 17 00:00:00 2001 From: Francois Best Date: Mon, 5 Feb 2024 22:26:23 +0100 Subject: [PATCH 14/34] chore: Query spy with reactive useSearchParams --- .../(demos)/_components/query-spy.tsx | 34 ++++--------------- 1 file changed, 7 insertions(+), 27 deletions(-) diff --git a/packages/docs/src/app/playground/(demos)/_components/query-spy.tsx b/packages/docs/src/app/playground/(demos)/_components/query-spy.tsx index ea36764f5..3147c1348 100644 --- a/packages/docs/src/app/playground/(demos)/_components/query-spy.tsx +++ b/packages/docs/src/app/playground/(demos)/_components/query-spy.tsx @@ -1,50 +1,30 @@ 'use client' import { useSearchParams } from 'next/navigation' -import { subscribeToQueryUpdates } from 'nuqs' import React from 'react' import { QuerySpySkeleton } from './query-spy.skeleton' export function QuerySpy(props: React.ComponentProps<'pre'>) { - const initialSearchParams = useSearchParams() - const [search, setSearch] = React.useState(() => { - if (typeof location !== 'object') { - // SSR - const out = new URLSearchParams() - if (!initialSearchParams) { - return out - } - for (const [key, value] of initialSearchParams) { - out.set(key, value) - } - return out - } else { - return new URLSearchParams(location.search) - } - }) - - React.useLayoutEffect( - () => subscribeToQueryUpdates(({ search }) => setSearch(search)), - [] - ) - + const searchParams = useSearchParams() return ( - {search.size > 0 && ( + {searchParams.size > 0 && ( ? - {Array.from(search.entries()).map(([key, value], i) => ( + {Array.from(searchParams.entries()).map(([key, value], i) => ( {key}= {value} - {i < search.size - 1 && &} + {i < searchParams.size - 1 && ( + & + )} ))} )} - {search.size === 0 && ( + {searchParams.size === 0 && ( {''} )} From 51e8c4fdefb4f26f8005d55b687c088c485c1a29 Mon Sep 17 00:00:00 2001 From: Francois Best Date: Mon, 5 Feb 2024 22:45:35 +0100 Subject: [PATCH 15/34] doc: Query spy follows encoding Using a hack around the serializer to apply the same encoding as the hooks would in the URL. --- .../(demos)/_components/query-spy.tsx | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/packages/docs/src/app/playground/(demos)/_components/query-spy.tsx b/packages/docs/src/app/playground/(demos)/_components/query-spy.tsx index 3147c1348..2ad8c0346 100644 --- a/packages/docs/src/app/playground/(demos)/_components/query-spy.tsx +++ b/packages/docs/src/app/playground/(demos)/_components/query-spy.tsx @@ -1,32 +1,52 @@ 'use client' import { useSearchParams } from 'next/navigation' +import { createSerializer } from 'nuqs/server' import React from 'react' import { QuerySpySkeleton } from './query-spy.skeleton' +const serialize = createSerializer({}) + export function QuerySpy(props: React.ComponentProps<'pre'>) { - const searchParams = useSearchParams() + useSearchParams() // Just using it to trigger re-render on query change + const searchParams = parseQuery( + serialize(new URLSearchParams(location.search), {}).slice(1) // Remove leading '?' + ) + return ( - {searchParams.size > 0 && ( + {searchParams.length > 0 && ( ? - {Array.from(searchParams.entries()).map(([key, value], i) => ( + {searchParams.map(([key, value], i) => ( {key}= {value} - {i < searchParams.size - 1 && ( + {i < searchParams.length - 1 && ( & )} ))} )} - {searchParams.size === 0 && ( + {searchParams.length === 0 && ( {''} )} ) } + +function parseQuery(queryString: string): [string, string][] { + const elements = queryString.split('&') + if (elements.length === 0) return [] + return elements.reduce( + (acc, element) => { + if (element === '') return acc + const [key, value] = element.split('=') + return [...acc, [key, value]] + }, + [] as [string, string][] + ) +} From 734e6e5e145a35a9650d4afec0eaa0f608c2d238 Mon Sep 17 00:00:00 2001 From: Francois Best Date: Sat, 17 Feb 2024 13:40:21 +0100 Subject: [PATCH 16/34] test: Remove support for unstable shallow routing Next.js versions between 14.0.2 and 14.1.0 contain bugs on shallow routing (or other routing code) that makes `nuqs` hard to maintain for these versions. --- .github/workflows/ci-cd.yml | 12 +++--------- packages/e2e/cypress.config.ts | 14 -------------- packages/e2e/cypress/e2e/internals.cy.js | 5 +---- packages/e2e/cypress/e2e/repro-498.cy.js | 1 - packages/e2e/next.config.mjs | 19 +++---------------- packages/e2e/src/app/app/internals/page.tsx | 19 ++++++++++++++++--- packages/nuqs/package.json | 2 +- 7 files changed, 24 insertions(+), 48 deletions(-) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 319d7a9f8..a82558637 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -13,7 +13,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 @@ -22,7 +22,6 @@ jobs: # update the required checks in GitHub # branch protection settings for `next`. base-path: [false, '/base'] - window-history-support: [false] next-version: - '13.4' # App router in GA - '13.5' @@ -32,10 +31,6 @@ jobs: # which creates other problems under other versions of Next.js - '14.0.4' - latest # Current latest is 14.1.0 - include: - # 14.0.4 doesn't require the WHS flag, but supports it - - next-version: '14.0.4' - window-history-support: true steps: - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 @@ -52,7 +47,6 @@ jobs: run: pnpm run test 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 }} @@ -61,13 +55,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 }} diff --git a/packages/e2e/cypress.config.ts b/packages/e2e/cypress.config.ts index 50f5906ce..b5ea35dc6 100644 --- a/packages/e2e/cypress.config.ts +++ b/packages/e2e/cypress.config.ts @@ -7,12 +7,6 @@ const basePath = const nextJsVersion = readNextJsVersion() -const windowHistorySupport = supportsWHS(nextJsVersion) - ? process.env.WINDOW_HISTORY_SUPPORT === 'true' - ? 'true' - : 'false' - : 'undefined' - export default defineConfig({ e2e: { baseUrl: `http://localhost:3001${basePath}`, @@ -23,7 +17,6 @@ export default defineConfig({ retries: 2, env: { basePath, - windowHistorySupport, supportsShallowRouting: supportsShallowRouting(nextJsVersion), nextJsVersion } @@ -36,13 +29,6 @@ function readNextJsVersion() { return pkg.dependencies.next.replace('^', '') } -function supportsWHS(nextVersion: string) { - return ( - semver.gte(nextVersion, '14.0.3-canary.6') && - semver.lt(nextVersion, '14.0.5-canary.54') - ) -} - function supportsShallowRouting(nextVersion: string) { return semver.gte(nextVersion, '14.1.0') } diff --git a/packages/e2e/cypress/e2e/internals.cy.js b/packages/e2e/cypress/e2e/internals.cy.js index c6ca025de..6a3921eb0 100644 --- a/packages/e2e/cypress/e2e/internals.cy.js +++ b/packages/e2e/cypress/e2e/internals.cy.js @@ -4,9 +4,6 @@ describe('internals', () => { it('works in app router', () => { cy.visit('/app/internals') cy.contains('#hydration-marker', 'hydrated').should('be.hidden') - cy.get('#windowHistorySupport').should( - 'have.text', - Cypress.env('windowHistorySupport') - ) + cy.get('#nextJsVersion').should('have.text', Cypress.env('nextJsVersion')) }) }) diff --git a/packages/e2e/cypress/e2e/repro-498.cy.js b/packages/e2e/cypress/e2e/repro-498.cy.js index 547c97b2d..145d0244c 100644 --- a/packages/e2e/cypress/e2e/repro-498.cy.js +++ b/packages/e2e/cypress/e2e/repro-498.cy.js @@ -1,7 +1,6 @@ /// if ( - Cypress.env('windowHistorySupport') !== 'true' && ['14.1.0', '14.1.1'].includes(Cypress.env('nextJsVersion')) === false // See issue #498 ) { it('Reproduction for issue #498', () => { diff --git a/packages/e2e/next.config.mjs b/packages/e2e/next.config.mjs index d8e2c75db..12f567319 100644 --- a/packages/e2e/next.config.mjs +++ b/packages/e2e/next.config.mjs @@ -1,20 +1,12 @@ -const experimental = - process.env.WINDOW_HISTORY_SUPPORT === 'true' - ? { - windowHistorySupport: true, - clientRouterFilter: false - } - : { - clientRouterFilter: false - } - const basePath = process.env.BASE_PATH === '/' ? undefined : process.env.BASE_PATH /** @type {import('next').NextConfig } */ const config = { basePath, - experimental, + experimental: { + clientRouterFilter: false + }, rewrites: async () => [ { source: '/app/rewrites/source', @@ -28,9 +20,4 @@ const config = { ] } -console.info(`Next.js config: - basePath: ${basePath} - windowHistorySupport: ${experimental?.windowHistorySupport ?? false} -`) - export default config diff --git a/packages/e2e/src/app/app/internals/page.tsx b/packages/e2e/src/app/app/internals/page.tsx index 20f170b5d..97fc39297 100644 --- a/packages/e2e/src/app/app/internals/page.tsx +++ b/packages/e2e/src/app/app/internals/page.tsx @@ -1,7 +1,20 @@ +'use client' + +import React, { Suspense } from 'react' + export default function Page() { return ( -

- {String(process.env.__NEXT_WINDOW_HISTORY_SUPPORT)} -

+ + + ) } + +function Client() { + const [nextJsVersion, setNextJsVersion] = React.useState(null) + React.useEffect(() => { + // @ts-expect-error + setNextJsVersion(window.next?.version) + }, []) + return

{nextJsVersion}

+} diff --git a/packages/nuqs/package.json b/packages/nuqs/package.json index 600eec1c4..c9aa43031 100644 --- a/packages/nuqs/package.json +++ b/packages/nuqs/package.json @@ -56,7 +56,7 @@ "prepack": "./scripts/prepack.sh" }, "peerDependencies": { - "next": ">=13.4 <14.0.2 || ^14.0.4", + "next": ">=13.4 <14.0.2 || >=14.1.1", "react": "^18.2.0" }, "dependencies": { From 36b21d834bf0450efd82ac4727f468b3f8d65dfb Mon Sep 17 00:00:00 2001 From: Francois Best Date: Tue, 26 Mar 2024 10:07:57 +0100 Subject: [PATCH 17/34] chore: Peer dep on fixed version for #498 --- packages/nuqs/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nuqs/package.json b/packages/nuqs/package.json index c9aa43031..dfff01812 100644 --- a/packages/nuqs/package.json +++ b/packages/nuqs/package.json @@ -56,7 +56,7 @@ "prepack": "./scripts/prepack.sh" }, "peerDependencies": { - "next": ">=13.4 <14.0.2 || >=14.1.1", + "next": ">=13.4 <14.0.2 || >=14.1.2", "react": "^18.2.0" }, "dependencies": { From a6f22074fd468d7ca26f885be7df02922b67fe54 Mon Sep 17 00:00:00 2001 From: Francois Best Date: Tue, 26 Mar 2024 10:10:01 +0100 Subject: [PATCH 18/34] chore: Update Next.js version range to test against --- .github/workflows/ci-cd.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index a82558637..3ce4bd6b0 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -26,11 +26,13 @@ jobs: - '13.4' # App router in GA - '13.5' - '14.0.1' + # 14.0.2 to 14.1.1 included are skipped as shallow routing + # implementation was experimental and unstable after GA release. # 14.0.2 is not compatible due to a prefetch issue # 14.0.3 is dropped due to a bug requiring WHS + syncing against uSP, # which creates other problems under other versions of Next.js - - '14.0.4' - - latest # Current latest is 14.1.0 + - '14.1.2' + - latest steps: - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 From 9e9d3b416d79c1d463ee5b9cc6acf0340d1ae744 Mon Sep 17 00:00:00 2001 From: Francois Best Date: Thu, 27 Jun 2024 22:49:36 +0200 Subject: [PATCH 19/34] test: Test v2 against the React Compiler --- .../workflows/test-against-nextjs-release.yml | 8 +- packages/e2e/next.config.mjs | 3 +- packages/e2e/package.json | 1 + pnpm-lock.yaml | 142 ++++++++++++++++++ turbo.json | 4 +- 5 files changed, 152 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test-against-nextjs-release.yml b/.github/workflows/test-against-nextjs-release.yml index e36e1e407..85be4036d 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@692973e3d937129bcbf40652eb9f2f61becf3332 - 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/packages/e2e/next.config.mjs b/packages/e2e/next.config.mjs index 12f567319..64a73ee46 100644 --- a/packages/e2e/next.config.mjs +++ b/packages/e2e/next.config.mjs @@ -5,7 +5,8 @@ const basePath = const config = { basePath, experimental: { - clientRouterFilter: false + clientRouterFilter: false, + reactCompiler: process.env.REACT_COMPILER === 'true' }, rewrites: async () => [ { diff --git a/packages/e2e/package.json b/packages/e2e/package.json index 569f7c220..a1471b196 100644 --- a/packages/e2e/package.json +++ b/packages/e2e/package.json @@ -32,6 +32,7 @@ "@types/semver": "^7.5.8", "@types/webpack": "^5.28.5", "cypress": "^13.14.1", + "babel-plugin-react-compiler": "0.0.0-experimental-696af53-20240625", "npm-run-all": "^4.1.5", "semver": "^7.6.3", "typescript": "^5.5.4" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b6d0889a9..37e4fcbff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -208,6 +208,9 @@ importers: '@types/webpack': specifier: ^5.28.5 version: 5.28.5 + babel-plugin-react-compiler: + specifier: 0.0.0-experimental-696af53-20240625 + version: 0.0.0-experimental-696af53-20240625 cypress: specifier: ^13.14.1 version: 13.14.1 @@ -307,6 +310,13 @@ packages: resolution: {integrity: sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==} engines: {node: '>=6.9.0'} + '@babel/generator@7.2.0': + resolution: {integrity: sha512-BA75MVfRlFQG2EZgFYIwyT1r6xSkwfP2bdkY/kLZusEYWiJs4xCowab/alaEaT0wSvmVuXGqiefeBlP+7V1yKg==} + + '@babel/helper-string-parser@7.24.7': + resolution: {integrity: sha512-7MbVt6xrwFQbunH2DNQsAP5sTGxfqQtErvBIvIMi6EQnbgUOuVYanvREcmFrOPhoXBrTtjhhP+lW+o5UfK+tDg==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.24.7': resolution: {integrity: sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==} engines: {node: '>=6.9.0'} @@ -319,6 +329,10 @@ packages: resolution: {integrity: sha512-VBj9MYyDb9tuLq7yzqjgzt6Q+IBQLrGZfdjOekyEirZPHxXWoTSGUTMrpsfi58Up73d13NfYLv8HT9vmznjzhQ==} engines: {node: '>=6.9.0'} + '@babel/types@7.24.7': + resolution: {integrity: sha512-XEFXSlxiG5td2EJRe8vOmRbaXVgfcBlszKujvVmWIK/UpywWljQCfzAv3RQCGujWQ1RD4YYWEAqDXfuJiy8f5Q==} + engines: {node: '>=6.9.0'} + '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} @@ -736,6 +750,10 @@ packages: resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jest/types@24.9.0': + resolution: {integrity: sha512-XKK7ze1apu5JWQ5eZjHITP66AX+QsLlbaJRBGYr8pNzwcAE2JVkwnf0yqjHTsDRcjR0mujy/NmZMXw5kl+kGBw==} + engines: {node: '>= 6'} + '@jridgewell/gen-mapping@0.3.5': resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} engines: {node: '>=6.0.0'} @@ -1742,6 +1760,15 @@ packages: '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + + '@types/istanbul-reports@1.1.2': + resolution: {integrity: sha512-P/W9yOX/3oPZSpaYOCQzGqgCQRXn0FFO/V8bWrCQs+wLmvVVxk6CRBXALEvNs9OHIatlnlFokfhuDo2ug01ciw==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -1799,6 +1826,12 @@ packages: '@types/webpack@5.28.5': resolution: {integrity: sha512-wR87cgvxj3p6D0Crt1r5avwqffqPXUkNlnQ1mjU93G7gCuFjufZR4I6j8cz5g1F1tTYpfOOFvly+cmIQwL9wvw==} + '@types/yargs-parser@21.0.3': + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + + '@types/yargs@13.0.12': + resolution: {integrity: sha512-qCxJE1qgz2y0hA4pIxjBR+PelCH0U5CK1XJXFwCNqfmliatKp47UCXXE9Dyk1OXBDLvsCF57TqQEJaeLfDYEOQ==} + '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} @@ -1933,6 +1966,10 @@ packages: resolution: {integrity: sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==} engines: {node: '>=18'} + ansi-regex@4.1.1: + resolution: {integrity: sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==} + engines: {node: '>=6'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -2056,6 +2093,9 @@ packages: axios@0.21.4: resolution: {integrity: sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==} + babel-plugin-react-compiler@0.0.0-experimental-696af53-20240625: + resolution: {integrity: sha512-OUDKms8qmcm5bX0D+sJWC1YcKcd7AZ2aJ7eY6gkR+Xr7PDfkXLbqAld4Qs9B0ntjVbUMEtW/PjlQrxDtY4raHg==} + bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} @@ -3454,6 +3494,11 @@ packages: jsbn@0.1.1: resolution: {integrity: sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==} + jsesc@2.5.2: + resolution: {integrity: sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==} + engines: {node: '>=4'} + hasBin: true + json-parse-better-errors@1.0.2: resolution: {integrity: sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==} @@ -4484,6 +4529,10 @@ packages: resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==} engines: {node: ^14.13.1 || >=16.0.0} + pretty-format@24.9.0: + resolution: {integrity: sha512-00ZMZUiHaJrNfk33guavqgvfJS30sLYf0f8+Srklv0AMPodGGHcoHgksZ3OThYnIvOd+8yMCn0YiEOogjlgsnA==} + engines: {node: '>= 6'} + pretty-format@29.7.0: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -4973,6 +5022,10 @@ packages: source-map-support@0.5.21: resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + source-map@0.5.7: + resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} + engines: {node: '>=0.10.0'} + source-map@0.6.1: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} @@ -5254,6 +5307,10 @@ packages: resolution: {integrity: sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==} engines: {node: '>=14.14'} + to-fast-properties@2.0.0: + resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} + engines: {node: '>=4'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -5286,6 +5343,10 @@ packages: resolution: {integrity: sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==} engines: {node: '>=8'} + trim-right@1.0.1: + resolution: {integrity: sha512-WZGXGstmCWgeevgTL54hrCuw1dyMQIzWy7ZfqRJfSmJZBwklI15egmQytFP6bPidmw3M8d5yEowl1niq4vmqZw==} + engines: {node: '>=0.10.0'} + trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} @@ -5765,6 +5826,15 @@ packages: resolution: {integrity: sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==} engines: {node: '>=18'} + zod-validation-error@2.1.0: + resolution: {integrity: sha512-VJh93e2wb4c3tWtGgTa0OF/dTt/zoPCPzXq4V11ZjxmEAFaPi/Zss1xIZdEB5RD8GD00U0/iVXgqkF77RV7pdQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.18.0 + + zod@3.23.6: + resolution: {integrity: sha512-RTHJlZhsRbuA8Hmp/iNL7jnfc4nZishjsanDAfEY1QpDQZCahUp3xDzl+zfweE9BklxMUcgBgS1b7Lvie/ZVwA==} + zod@3.23.8: resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} @@ -5780,6 +5850,16 @@ snapshots: '@babel/highlight': 7.24.7 picocolors: 1.0.1 + '@babel/generator@7.2.0': + dependencies: + '@babel/types': 7.24.7 + jsesc: 2.5.2 + lodash: 4.17.21 + source-map: 0.5.7 + trim-right: 1.0.1 + + '@babel/helper-string-parser@7.24.7': {} + '@babel/helper-validator-identifier@7.24.7': {} '@babel/highlight@7.24.7': @@ -5793,6 +5873,12 @@ snapshots: dependencies: regenerator-runtime: 0.14.1 + '@babel/types@7.24.7': + dependencies: + '@babel/helper-string-parser': 7.24.7 + '@babel/helper-validator-identifier': 7.24.7 + to-fast-properties: 2.0.0 + '@colors/colors@1.5.0': optional: true @@ -6137,6 +6223,12 @@ snapshots: dependencies: '@sinclair/typebox': 0.27.8 + '@jest/types@24.9.0': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 1.1.2 + '@types/yargs': 13.0.12 + '@jridgewell/gen-mapping@0.3.5': dependencies: '@jridgewell/set-array': 1.2.1 @@ -7256,6 +7348,17 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/istanbul-reports@1.1.2': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-lib-report': 3.0.3 + '@types/json-schema@7.0.15': {} '@types/mdast@4.0.4': @@ -7316,6 +7419,12 @@ snapshots: - uglify-js - webpack-cli + '@types/yargs-parser@21.0.3': {} + + '@types/yargs@13.0.12': + dependencies: + '@types/yargs-parser': 21.0.3 + '@types/yauzl@2.10.3': dependencies: '@types/node': 20.16.3 @@ -7501,6 +7610,8 @@ snapshots: dependencies: environment: 1.1.0 + ansi-regex@4.1.1: {} + ansi-regex@5.0.1: {} ansi-regex@6.0.1: {} @@ -7608,6 +7719,16 @@ snapshots: transitivePeerDependencies: - debug + babel-plugin-react-compiler@0.0.0-experimental-696af53-20240625: + dependencies: + '@babel/generator': 7.2.0 + '@babel/types': 7.24.7 + chalk: 4.1.2 + invariant: 2.2.4 + pretty-format: 24.9.0 + zod: 3.23.6 + zod-validation-error: 2.1.0(zod@3.23.6) + bail@2.0.2: {} balanced-match@1.0.2: {} @@ -9204,6 +9325,8 @@ snapshots: jsbn@0.1.1: {} + jsesc@2.5.2: {} + json-parse-better-errors@1.0.2: {} json-parse-even-better-errors@2.3.1: {} @@ -10391,6 +10514,13 @@ snapshots: pretty-bytes@6.1.1: {} + pretty-format@24.9.0: + dependencies: + '@jest/types': 24.9.0 + ansi-regex: 4.1.1 + ansi-styles: 3.2.1 + react-is: 16.13.1 + pretty-format@29.7.0: dependencies: '@jest/schemas': 29.6.3 @@ -11019,6 +11149,8 @@ snapshots: buffer-from: 1.1.2 source-map: 0.6.1 + source-map@0.5.7: {} + source-map@0.6.1: {} source-map@0.7.4: {} @@ -11315,6 +11447,8 @@ snapshots: tmp@0.2.3: {} + to-fast-properties@2.0.0: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -11344,6 +11478,8 @@ snapshots: trim-newlines@3.0.1: {} + trim-right@1.0.1: {} + trough@2.2.0: {} ts-interface-checker@0.1.13: {} @@ -11896,6 +12032,12 @@ snapshots: yoctocolors@2.1.1: {} + zod-validation-error@2.1.0(zod@3.23.6): + dependencies: + zod: 3.23.6 + + zod@3.23.6: {} + zod@3.23.8: {} zwitch@2.0.4: {} diff --git a/turbo.json b/turbo.json index f2a05cb27..4ac44cbb8 100644 --- a/turbo.json +++ b/turbo.json @@ -14,7 +14,7 @@ "e2e#build": { "outputs": [".next/**", "!.next/cache/**", "cypress/**"], "dependsOn": ["^build"], - "env": ["BASE_PATH", "WINDOW_HISTORY_SUPPORT", "E2E_NO_CACHE_ON_RERUN"] + "env": ["BASE_PATH", "REACT_COMPILER", "E2E_NO_CACHE_ON_RERUN"] }, "docs#build": { "outputs": [".next/**", "!.next/cache/**"], @@ -27,7 +27,7 @@ }, "e2e#test": { "dependsOn": ["build"], - "env": ["BASE_PATH", "WINDOW_HISTORY_SUPPORT", "E2E_NO_CACHE_ON_RERUN"] + "env": ["BASE_PATH", "REACT_COMPILER", "E2E_NO_CACHE_ON_RERUN"] } } } From e6c94edbbcbc197be570f4a7dc3b3e3bf345e26c Mon Sep 17 00:00:00 2001 From: Francois Best Date: Fri, 28 Jun 2024 09:09:43 +0200 Subject: [PATCH 20/34] chore: Don't define the compiler flag when false --- packages/e2e/next.config.mjs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/e2e/next.config.mjs b/packages/e2e/next.config.mjs index 64a73ee46..14b72acc3 100644 --- a/packages/e2e/next.config.mjs +++ b/packages/e2e/next.config.mjs @@ -6,7 +6,7 @@ const config = { basePath, experimental: { clientRouterFilter: false, - reactCompiler: process.env.REACT_COMPILER === 'true' + ...(process.env.REACT_COMPILER === 'true' ? { reactCompiler: true } : {}) }, rewrites: async () => [ { @@ -21,4 +21,9 @@ const config = { ] } +console.info(`Next.js config: + basePath: ${basePath} + reactCompiler: ${experimental?.reactCompiler ?? false} +`) + export default config From dc97d2c57e308955ece7710b4ce38f60d2c63764 Mon Sep 17 00:00:00 2001 From: Francois Best Date: Fri, 28 Jun 2024 11:42:11 +0200 Subject: [PATCH 21/34] chore: Fix config access --- packages/e2e/next.config.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/e2e/next.config.mjs b/packages/e2e/next.config.mjs index 14b72acc3..c3d33d8c1 100644 --- a/packages/e2e/next.config.mjs +++ b/packages/e2e/next.config.mjs @@ -23,7 +23,7 @@ const config = { console.info(`Next.js config: basePath: ${basePath} - reactCompiler: ${experimental?.reactCompiler ?? false} + reactCompiler: ${config.experimental.reactCompiler ?? false} `) export default config From 39dd6be1641e809c86b7756e3fc4a01c71047a40 Mon Sep 17 00:00:00 2001 From: Francois Best Date: Fri, 26 Jul 2024 10:29:37 +0100 Subject: [PATCH 22/34] chore: Only support next@14.2.0 and above --- .github/workflows/ci-cd.yml | 16 ++++++++-------- packages/nuqs/package.json | 4 ++-- packages/nuqs/src/update-queue.ts | 11 +++-------- 3 files changed, 13 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 3ce4bd6b0..ddbee790e 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -23,15 +23,15 @@ jobs: # branch protection settings for `next`. base-path: [false, '/base'] next-version: - - '13.4' # App router in GA - - '13.5' - - '14.0.1' - # 14.0.2 to 14.1.1 included are skipped as shallow routing - # implementation was experimental and unstable after GA release. - # 14.0.2 is not compatible due to a prefetch issue - # 14.0.3 is dropped due to a bug requiring WHS + syncing against uSP, - # which creates other problems under other versions of Next.js - '14.1.2' + # - '14.1.3' + # - '14.1.4' + # - '14.2.0' + # - '14.2.1' + # - '14.2.2' + # - '14.2.3' + # - '14.2.4' + - '14.2.5' - latest steps: diff --git a/packages/nuqs/package.json b/packages/nuqs/package.json index dfff01812..855cbe6ad 100644 --- a/packages/nuqs/package.json +++ b/packages/nuqs/package.json @@ -56,8 +56,8 @@ "prepack": "./scripts/prepack.sh" }, "peerDependencies": { - "next": ">=13.4 <14.0.2 || >=14.1.2", - "react": "^18.2.0" + "next": ">= 14.1.2", + "react": ">= 18.2.0" }, "dependencies": { "mitt": "^3.0.1" diff --git a/packages/nuqs/src/update-queue.ts b/packages/nuqs/src/update-queue.ts index 03e6efb13..cbfdba2dc 100644 --- a/packages/nuqs/src/update-queue.ts +++ b/packages/nuqs/src/update-queue.ts @@ -186,16 +186,11 @@ function flushUpdateQueue(router: Router): [URLSearchParams, null | unknown] { // this allows keeping a reactive URL if the network is slow. const updateMethod = options.history === 'push' ? history.pushState : history.replaceState - // In 14.1.0, useSearchParams becomes reactive to shallow updates, - // but only if passing `null` as the history state. - // Older versions need to maintain the history state for push to work. - // This should theoretically be checking for >=14.0.5-canary.54 where WHS - // was stabilised, but we're not supporting canaries from previous GAs. - const state = - (window.next?.version ?? '') >= '14.1.0' ? null : history.state updateMethod.call( history, - state, + // In next@14.1.0, useSearchParams becomes reactive to shallow updates, + // but only if passing `null` as the history state. + null, // Our own updates have a marker to prevent syncing // when the URL changes (we've already sync'd them up // via `emitter.emit(key, newValue)` above, without From c556883b05805f99810e983bb2b119eb5eb9b6f3 Mon Sep 17 00:00:00 2001 From: Francois Best Date: Fri, 6 Sep 2024 14:55:54 +0200 Subject: [PATCH 23/34] chore: Fix linter by sorting devDeps alphabetically --- packages/e2e/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/e2e/package.json b/packages/e2e/package.json index a1471b196..fc5b3a221 100644 --- a/packages/e2e/package.json +++ b/packages/e2e/package.json @@ -31,8 +31,8 @@ "@types/react-dom": "^18.3.0", "@types/semver": "^7.5.8", "@types/webpack": "^5.28.5", - "cypress": "^13.14.1", "babel-plugin-react-compiler": "0.0.0-experimental-696af53-20240625", + "cypress": "^13.14.1", "npm-run-all": "^4.1.5", "semver": "^7.6.3", "typescript": "^5.5.4" From fa0b732615259080f776bc996d48bb62bcf6b756 Mon Sep 17 00:00:00 2001 From: Francois Best Date: Fri, 6 Sep 2024 14:58:09 +0200 Subject: [PATCH 24/34] chore: Test all supported next@^14 versions --- .github/workflows/ci-cd.yml | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index ddbee790e..5a261202a 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -24,14 +24,16 @@ jobs: base-path: [false, '/base'] next-version: - '14.1.2' - # - '14.1.3' - # - '14.1.4' - # - '14.2.0' - # - '14.2.1' - # - '14.2.2' - # - '14.2.3' - # - '14.2.4' + - '14.1.3' + - '14.1.4' + - '14.2.0' + - '14.2.1' + - '14.2.2' + - '14.2.3' + - '14.2.4' - '14.2.5' + - '14.2.6' + - '14.2.7' - latest steps: From 9c93ff2a52ad07ccb1329529bf3f122f300c9313 Mon Sep 17 00:00:00 2001 From: Francois Best Date: Fri, 6 Sep 2024 23:30:08 +0200 Subject: [PATCH 25/34] chore: Replace history patching with uSP sync --- packages/nuqs/src/sync.ts | 92 ----------------------------- packages/nuqs/src/update-queue.ts | 7 +-- packages/nuqs/src/useQueryState.ts | 12 ++++ packages/nuqs/src/useQueryStates.ts | 15 +++++ 4 files changed, 28 insertions(+), 98 deletions(-) diff --git a/packages/nuqs/src/sync.ts b/packages/nuqs/src/sync.ts index 99bf74d29..1e9021c5e 100644 --- a/packages/nuqs/src/sync.ts +++ b/packages/nuqs/src/sync.ts @@ -1,10 +1,6 @@ import Mitt from 'mitt' -import { debug } from './debug' -import { error } from './errors' -import { getQueuedValue } from './update-queue' export const SYNC_EVENT_KEY = Symbol('__nuqs__SYNC__') -export const NOSYNC_MARKER = '__nuqs__NO_SYNC__' const NOTIFY_EVENT_KEY = Symbol('__nuqs__NOTIFY__') export type QueryUpdateSource = 'internal' | 'external' @@ -24,91 +20,3 @@ type EventMap = { } export const emitter = Mitt() - -declare global { - interface History { - __nuqs_patched?: string - } -} - -if (typeof history === 'object') { - patchHistory() -} - -function patchHistory() { - // This is replaced with the package.json version by scripts/prepack.sh - // after semantic-release has done updating the version number. - const version = '0.0.0-inject-version-here' - const patched = history.__nuqs_patched - if (patched) { - if (patched !== version) { - console.error(error(409), patched, version) - } - return - } - debug('[nuqs] Patching history with %s', version) - for (const method of ['pushState', 'replaceState'] as const) { - const original = history[method].bind(history) - history[method] = function nuqs_patchedHistory( - state: any, - title: string, - url?: string | URL | null - ) { - if (!url) { - // Null URL is only used for state changes, - // we're not interested in reacting to those. - debug('[nuqs] history.%s(null) (%s) %O', method, title, state) - return original(state, title, url) - } - const source = title === NOSYNC_MARKER ? 'internal' : 'external' - const search = new URL(url, location.origin).searchParams - debug('[nuqs] history.%s(%s) (%s) %O', method, url, source, state) - // If someone else than our hooks have updated the URL, - // send out a signal for them to sync their internal state. - if (source === 'external') { - for (const [key, value] of search.entries()) { - const queueValue = getQueuedValue(key) - if (queueValue !== null && queueValue !== value) { - debug( - '[nuqs] Overwrite detected for key: %s, Server: %s, queue: %s', - key, - value, - queueValue - ) - search.set(key, queueValue) - } - } - // Here we're delaying application to next tick to avoid: - // `Warning: useInsertionEffect must not schedule updates.` - // - // Because the emitter runs in sync, this would trigger - // each hook's setInternalState updates, so we schedule - // those after the current batch of events. - // Because we don't know if the history method would - // have been applied by then, we're also sending the - // parsed query string to the hooks so they don't need - // to rely on the URL being up to date. - setTimeout(() => { - debug( - '[nuqs] External history.%s call: triggering sync with %s', - method, - search - ) - emitter.emit(SYNC_EVENT_KEY, search) - emitter.emit(NOTIFY_EVENT_KEY, { search, source }) - }, 0) - } else { - setTimeout(() => { - emitter.emit(NOTIFY_EVENT_KEY, { search, source }) - }, 0) - } - return original(state, title === NOSYNC_MARKER ? '' : title, url) - } - } - Object.defineProperty(history, '__nuqs_patched', { - value: version, - writable: false, - enumerable: false, - configurable: false - }) -} diff --git a/packages/nuqs/src/update-queue.ts b/packages/nuqs/src/update-queue.ts index cbfdba2dc..82c08cce6 100644 --- a/packages/nuqs/src/update-queue.ts +++ b/packages/nuqs/src/update-queue.ts @@ -2,7 +2,6 @@ import type { NextRouter } from 'next/router' import { debug } from './debug' import type { Options, Router } from './defs' import { error } from './errors' -import { NOSYNC_MARKER } from './sync' import { renderQueryString } from './url-encoding' import { getDefaultThrottle } from './utils' @@ -191,11 +190,7 @@ function flushUpdateQueue(router: Router): [URLSearchParams, null | unknown] { // In next@14.1.0, useSearchParams becomes reactive to shallow updates, // but only if passing `null` as the history state. null, - // Our own updates have a marker to prevent syncing - // when the URL changes (we've already sync'd them up - // via `emitter.emit(key, newValue)` above, without - // going through the parsers). - NOSYNC_MARKER, + '', url ) if (options.scroll) { diff --git a/packages/nuqs/src/useQueryState.ts b/packages/nuqs/src/useQueryState.ts index 668358f00..c4cf858af 100644 --- a/packages/nuqs/src/useQueryState.ts +++ b/packages/nuqs/src/useQueryState.ts @@ -241,6 +241,18 @@ export function useQueryState( initialSearchParams?.get(key) ?? null ) + React.useEffect(() => { + const query = initialSearchParams.get(key) ?? null + if (query === queryRef.current) { + return + } + const state = query === null ? null : safeParse(parse, query, key) + debug('[nuqs `%s`] syncFromUseSearchParams %O', key, state) + stateRef.current = state + queryRef.current = query + setInternalState(state) + }, [initialSearchParams?.get(key), key]) + // Sync all hooks together & with external URL changes React.useInsertionEffect(() => { function updateInternalState({ state, query }: CrossHookSyncPayload) { diff --git a/packages/nuqs/src/useQueryStates.ts b/packages/nuqs/src/useQueryStates.ts index 82cef0914..6bf92b2f4 100644 --- a/packages/nuqs/src/useQueryStates.ts +++ b/packages/nuqs/src/useQueryStates.ts @@ -88,6 +88,21 @@ export function useQueryStates( initialSearchParams ) + React.useEffect(() => { + const state = parseMap( + keyMap, + initialSearchParams, + queryRef.current, + stateRef.current + ) + setInternalState(state) + }, [ + Object.keys(keyMap) + .map(key => initialSearchParams?.get(key)) + .join('&'), + keys + ]) + // Sync all hooks together & with external URL changes React.useInsertionEffect(() => { function updateInternalState(state: V) { From ca9971df7aef9462f0a5423728aa9bb5bbe19e49 Mon Sep 17 00:00:00 2001 From: Francois Best Date: Fri, 6 Sep 2024 23:42:12 +0200 Subject: [PATCH 26/34] chore: Remove notification types & exports --- packages/nuqs/src/sync.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/nuqs/src/sync.ts b/packages/nuqs/src/sync.ts index 1e9021c5e..d620e0c74 100644 --- a/packages/nuqs/src/sync.ts +++ b/packages/nuqs/src/sync.ts @@ -1,13 +1,7 @@ import Mitt from 'mitt' export const SYNC_EVENT_KEY = Symbol('__nuqs__SYNC__') -const NOTIFY_EVENT_KEY = Symbol('__nuqs__NOTIFY__') -export type QueryUpdateSource = 'internal' | 'external' -export type QueryUpdateNotificationArgs = { - search: URLSearchParams - source: QueryUpdateSource -} export type CrossHookSyncPayload = { state: any query: string | null @@ -15,7 +9,6 @@ export type CrossHookSyncPayload = { type EventMap = { [SYNC_EVENT_KEY]: URLSearchParams - [NOTIFY_EVENT_KEY]: QueryUpdateNotificationArgs [key: string]: CrossHookSyncPayload } From d31ff4e0774b8aa63b7fa907910e923f9f401ce4 Mon Sep 17 00:00:00 2001 From: Francois Best Date: Fri, 6 Sep 2024 23:43:10 +0200 Subject: [PATCH 27/34] chore: Remove exports --- packages/nuqs/src/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/nuqs/src/index.ts b/packages/nuqs/src/index.ts index 7a428df1c..f04640d68 100644 --- a/packages/nuqs/src/index.ts +++ b/packages/nuqs/src/index.ts @@ -3,6 +3,5 @@ export type { HistoryOptions, Options } from './defs' export * from './parsers' export { createSerializer } from './serializer' -export type { QueryUpdateNotificationArgs, QueryUpdateSource } from './sync' export * from './useQueryState' export * from './useQueryStates' From 0f012c395ab695ea4a4b968561c24806a57dd2b8 Mon Sep 17 00:00:00 2001 From: Francois Best Date: Fri, 6 Sep 2024 23:43:50 +0200 Subject: [PATCH 28/34] chore: No more side-effects --- packages/nuqs/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nuqs/package.json b/packages/nuqs/package.json index 855cbe6ad..ed38e1935 100644 --- a/packages/nuqs/package.json +++ b/packages/nuqs/package.json @@ -32,7 +32,7 @@ "server.d.ts" ], "type": "module", - "sideEffects": true, + "sideEffects": false, "module": "dist/index.js", "types": "dist/index.d.ts", "exports": { From 9e54b47a58c9192f52e54ee9b8ef35354db9a91c Mon Sep 17 00:00:00 2001 From: Francois Best Date: Sat, 7 Sep 2024 07:47:59 +0200 Subject: [PATCH 29/34] chore: Remove unused sync code --- packages/nuqs/src/sync.ts | 3 --- packages/nuqs/src/useQueryState.ts | 13 +------------ packages/nuqs/src/useQueryStates.ts | 10 +--------- 3 files changed, 2 insertions(+), 24 deletions(-) diff --git a/packages/nuqs/src/sync.ts b/packages/nuqs/src/sync.ts index d620e0c74..de529389d 100644 --- a/packages/nuqs/src/sync.ts +++ b/packages/nuqs/src/sync.ts @@ -1,14 +1,11 @@ import Mitt from 'mitt' -export const SYNC_EVENT_KEY = Symbol('__nuqs__SYNC__') - export type CrossHookSyncPayload = { state: any query: string | null } type EventMap = { - [SYNC_EVENT_KEY]: URLSearchParams [key: string]: CrossHookSyncPayload } diff --git a/packages/nuqs/src/useQueryState.ts b/packages/nuqs/src/useQueryState.ts index c4cf858af..7aa2feef8 100644 --- a/packages/nuqs/src/useQueryState.ts +++ b/packages/nuqs/src/useQueryState.ts @@ -3,7 +3,7 @@ import React from 'react' import { debug } from './debug' import type { Options } from './defs' import type { Parser } from './parsers' -import { SYNC_EVENT_KEY, emitter, type CrossHookSyncPayload } from './sync' +import { emitter, type CrossHookSyncPayload } from './sync' import { FLUSH_RATE_LIMIT_MS, enqueueQueryStringUpdate, @@ -261,21 +261,10 @@ export function useQueryState( queryRef.current = query setInternalState(state) } - function syncFromURL(search: URLSearchParams) { - const query = search.get(key) - if (query === queryRef.current) { - return - } - const state = query === null ? null : safeParse(parse, query, key) - debug('[nuqs `%s`] syncFromURL %O', key, state) - updateInternalState({ state, query }) - } debug('[nuqs `%s`] subscribing to sync', key) - emitter.on(SYNC_EVENT_KEY, syncFromURL) emitter.on(key, updateInternalState) return () => { debug('[nuqs `%s`] unsubscribing from sync', key) - emitter.off(SYNC_EVENT_KEY, syncFromURL) emitter.off(key, updateInternalState) } }, [key]) diff --git a/packages/nuqs/src/useQueryStates.ts b/packages/nuqs/src/useQueryStates.ts index 6bf92b2f4..f648d4d74 100644 --- a/packages/nuqs/src/useQueryStates.ts +++ b/packages/nuqs/src/useQueryStates.ts @@ -7,7 +7,7 @@ import React from 'react' import { debug } from './debug' import type { Nullable, Options } from './defs' import type { Parser } from './parsers' -import { SYNC_EVENT_KEY, emitter, type CrossHookSyncPayload } from './sync' +import { emitter, type CrossHookSyncPayload } from './sync' import { FLUSH_RATE_LIMIT_MS, enqueueQueryStringUpdate, @@ -110,11 +110,6 @@ export function useQueryStates( stateRef.current = state setInternalState(state) } - function syncFromURL(search: URLSearchParams) { - const state = parseMap(keyMap, search, queryRef.current, stateRef.current) - debug('[nuq+ `%s`] syncFromURL %O', keys, state) - updateInternalState(state) - } const handlers = Object.keys(keyMap).reduce( (handlers, key) => { handlers[key as keyof V] = ({ state, query }: CrossHookSyncPayload) => { @@ -140,14 +135,11 @@ export function useQueryStates( }, {} as Record void> ) - - emitter.on(SYNC_EVENT_KEY, syncFromURL) for (const key of Object.keys(keyMap)) { debug('[nuq+ `%s`] Subscribing to sync for `%s`', keys, key) emitter.on(key, handlers[key]!) } return () => { - emitter.off(SYNC_EVENT_KEY, syncFromURL) for (const key of Object.keys(keyMap)) { debug('[nuq+ `%s`] Unsubscribing to sync for `%s`', keys, key) emitter.off(key, handlers[key]) From 97a1ea8781b2a1928c138678f76b40792f1c7b68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Best?= Date: Mon, 9 Sep 2024 09:30:37 +0200 Subject: [PATCH 30/34] ref: Pure internal state init function (#633) - Follow the Rules of React to init the query ref once on mount - Remove access to the queued value, doesn't seem to change outcome of issue 359 --- packages/e2e/cypress/e2e/repro-359.cy.js | 8 ++++---- packages/nuqs/src/update-queue.ts | 4 ---- packages/nuqs/src/useQueryState.ts | 12 +++++------- packages/nuqs/src/useQueryStates.ts | 14 +++++++------- 4 files changed, 16 insertions(+), 22 deletions(-) diff --git a/packages/e2e/cypress/e2e/repro-359.cy.js b/packages/e2e/cypress/e2e/repro-359.cy.js index 7a1671bee..a9ed26cb6 100644 --- a/packages/e2e/cypress/e2e/repro-359.cy.js +++ b/packages/e2e/cypress/e2e/repro-359.cy.js @@ -11,7 +11,7 @@ it('repro-359', () => { cy.get('#nuqss-component').should('have.text', '') cy.contains('Component 1 (nuqs)').click() - cy.wait(100) + // cy.wait(100) cy.location('search').should('eq', '?param=comp1&component=comp1') cy.get('#comp1').should('have.text', 'comp1') cy.get('#comp2').should('not.exist') @@ -21,7 +21,7 @@ it('repro-359', () => { cy.get('#nuqss-component').should('have.text', 'comp1') cy.contains('Component 2 (nuqs)').click() - cy.wait(100) + // cy.wait(100) cy.location('search').should('eq', '?param=comp2&component=comp2') cy.get('#comp1').should('not.exist') cy.get('#comp2').should('have.text', 'comp2') @@ -31,7 +31,7 @@ it('repro-359', () => { cy.get('#nuqss-component').should('have.text', 'comp2') cy.contains('Component 1 (nuq+)').click() - cy.wait(100) + // cy.wait(100) cy.location('search').should('eq', '?param=comp1&component=comp1') cy.get('#comp1').should('have.text', 'comp1') cy.get('#comp2').should('not.exist') @@ -41,7 +41,7 @@ it('repro-359', () => { cy.get('#nuqss-component').should('have.text', 'comp1') cy.contains('Component 2 (nuq+)').click() - cy.wait(100) + // cy.wait(100) cy.location('search').should('eq', '?param=comp2&component=comp2') cy.get('#comp1').should('not.exist') cy.get('#comp2').should('have.text', 'comp2') diff --git a/packages/nuqs/src/update-queue.ts b/packages/nuqs/src/update-queue.ts index 82c08cce6..6f22bf7f2 100644 --- a/packages/nuqs/src/update-queue.ts +++ b/packages/nuqs/src/update-queue.ts @@ -57,10 +57,6 @@ export function enqueueQueryStringUpdate( return serializedOrNull } -export function getQueuedValue(key: string) { - return updateQueue.get(key) ?? null -} - /** * Eventually flush the update queue to the URL query string. * diff --git a/packages/nuqs/src/useQueryState.ts b/packages/nuqs/src/useQueryState.ts index 7aa2feef8..6b13cdc23 100644 --- a/packages/nuqs/src/useQueryState.ts +++ b/packages/nuqs/src/useQueryState.ts @@ -7,7 +7,6 @@ import { emitter, type CrossHookSyncPayload } from './sync' import { FLUSH_RATE_LIMIT_MS, enqueueQueryStringUpdate, - getQueuedValue, scheduleFlushToURL } from './update-queue' import { safeParse } from './utils' @@ -225,13 +224,12 @@ export function useQueryState( const router = useRouter() // Not reactive, but available on the server and on page load const initialSearchParams = useSearchParams() - const queryRef = React.useRef(null) + const queryRef = React.useRef( + initialSearchParams?.get(key) ?? null + ) const [internalState, setInternalState] = React.useState(() => { - const queueValue = getQueuedValue(key) - const urlValue = initialSearchParams?.get(key) ?? null - const value = queueValue ?? urlValue - queryRef.current = value - return value === null ? null : safeParse(parse, value, key) + const query = initialSearchParams?.get(key) ?? null + return query === null ? null : safeParse(parse, query, key) }) const stateRef = React.useRef(internalState) debug( diff --git a/packages/nuqs/src/useQueryStates.ts b/packages/nuqs/src/useQueryStates.ts index f648d4d74..3ac7a1264 100644 --- a/packages/nuqs/src/useQueryStates.ts +++ b/packages/nuqs/src/useQueryStates.ts @@ -11,7 +11,6 @@ import { emitter, type CrossHookSyncPayload } from './sync' import { FLUSH_RATE_LIMIT_MS, enqueueQueryStringUpdate, - getQueuedValue, scheduleFlushToURL } from './update-queue' import { safeParse } from './utils' @@ -74,9 +73,13 @@ export function useQueryStates( // Not reactive, but available on the server and on page load const initialSearchParams = useSearchParams() const queryRef = React.useRef>({}) + // Initialise the queryRef with the initial values + if (Object.keys(queryRef.current).length !== Object.keys(keyMap).length) { + queryRef.current = Object.fromEntries(initialSearchParams?.entries() ?? []) + } + const [internalState, setInternalState] = React.useState(() => { const source = initialSearchParams ?? new URLSearchParams() - queryRef.current = Object.fromEntries(source.entries()) return parseMap(keyMap, source) }) @@ -99,8 +102,7 @@ export function useQueryStates( }, [ Object.keys(keyMap) .map(key => initialSearchParams?.get(key)) - .join('&'), - keys + .join('&') ]) // Sync all hooks together & with external URL changes @@ -214,9 +216,7 @@ function parseMap( ) { return Object.keys(keyMap).reduce((obj, key) => { const { defaultValue, parse } = keyMap[key]! - const urlQuery = searchParams?.get(key) ?? null - const queueQuery = getQueuedValue(key) - const query = queueQuery ?? urlQuery + const query = searchParams?.get(key) ?? null if (cachedQuery && cachedState && cachedQuery[key] === query) { obj[key as keyof KeyMap] = cachedState[key] ?? defaultValue ?? null return obj From 330df9b954ca1f502294f256bd1b245f392f8034 Mon Sep 17 00:00:00 2001 From: Hugo Tiger <49451774+hugotiger@users.noreply.github.com> Date: Mon, 16 Sep 2024 11:35:00 +0200 Subject: [PATCH 31/34] feat: Sort search params keys --- packages/nuqs/src/url-encoding.test.ts | 23 +++++++++++++++++++++++ packages/nuqs/src/url-encoding.ts | 7 ++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/nuqs/src/url-encoding.test.ts b/packages/nuqs/src/url-encoding.test.ts index 762298e2b..72ba35446 100644 --- a/packages/nuqs/src/url-encoding.test.ts +++ b/packages/nuqs/src/url-encoding.test.ts @@ -99,6 +99,29 @@ describe('url-encoding/renderQueryString', () => { '?name=John+Doe&email=foo.bar%2Begg-spam@example.com&message=Hello,+world!+%23greeting' ) }) + test('it sorts keys', () => { + const search = new URLSearchParams() + search.set('a', '3') + search.set('b', '2') + search.set('c', '1') + const query = renderQueryString(search) + expect(query).toBe('?a=3&b=2&c=1') + }) + test('it preserves order of keys with the same name', () => { + const search = new URLSearchParams() + search.set('a', '3') + search.append('a', '2') + search.append('a', '1') + const query = renderQueryString(search) + expect(query).toBe('?a=3&a=2&a=1') + }) + test('it sorts by unicode code points', () => { + const search = new URLSearchParams() + search.set('😊', 'foo') + search.set('a', 'foo') + const query = renderQueryString(search) + expect(query).toBe('?a=foo&😊=foo') + }) test('practical use-cases', () => { // https://github.com/47ng/nuqs/issues/355 { diff --git a/packages/nuqs/src/url-encoding.ts b/packages/nuqs/src/url-encoding.ts index b1948d155..7757140ca 100644 --- a/packages/nuqs/src/url-encoding.ts +++ b/packages/nuqs/src/url-encoding.ts @@ -2,8 +2,13 @@ export function renderQueryString(search: URLSearchParams) { if (search.size === 0) { return '' } + + const clonedSearch = new URLSearchParams(search) + clonedSearch.sort() + const entries = clonedSearch.entries() + const query: string[] = [] - for (const [key, value] of search.entries()) { + for (const [key, value] of entries) { // Replace disallowed characters in keys, // see https://github.com/47ng/nuqs/issues/599 const safeKey = key From 2cae1ae7e4a85a2ad9a7942fd2e3fcc5d16211f0 Mon Sep 17 00:00:00 2001 From: Hugo Tiger <49451774+hugotiger@users.noreply.github.com> Date: Mon, 16 Sep 2024 11:42:11 +0200 Subject: [PATCH 32/34] test: Fix failing tests --- packages/nuqs/src/url-encoding.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/nuqs/src/url-encoding.test.ts b/packages/nuqs/src/url-encoding.test.ts index 72ba35446..b20e0e849 100644 --- a/packages/nuqs/src/url-encoding.test.ts +++ b/packages/nuqs/src/url-encoding.test.ts @@ -96,7 +96,7 @@ describe('url-encoding/renderQueryString', () => { search.set('message', 'Hello, world! #greeting') const query = renderQueryString(search) expect(query).toBe( - '?name=John+Doe&email=foo.bar%2Begg-spam@example.com&message=Hello,+world!+%23greeting' + '?email=foo.bar%2Begg-spam@example.com&message=Hello,+world!+%23greeting&name=John+Doe' ) }) test('it sorts keys', () => { @@ -137,7 +137,9 @@ describe('url-encoding/renderQueryString', () => { 'https://radverkehrsatlas.de/regionen/trto?lat=53.6774&lng=13.267&zoom=10.6&theme=fromTo&bg=default&config=!(i~fromTo~topics~!(i~shops~s~!(i~hidden~a~_F)(i~default~a))(i~education~s~!(i~hidden~a)(i~default~a~_F))(i~places~s~!(i~hidden~a~_F)(i~default~a)(i~circle~a~_F))(i~buildings~s~!(i~hidden~a)(i~default~a~_F))(i~landuse~s~!(i~hidden~a~_F)(i~default~a))(i~barriers~s~!(i~hidden~a~_F)(i~default~a))(i~boundaries~s~!(i~hidden~a)(i~default~a~_F)(i~level-8~a~_F)(i~level-9-10~a~_F)))(i~bikelanes~topics~!(i~bikelanes~s~!(i~hidden~a~_F)(i~default~a)(i~verification~a~_F)(i~completeness~a~_F))(i~bikelanesPresence*_legacy~s~!(i~hidden~a)(i~default~a~_F))(i~places~s~!(i~hidden~a~_F)(i~default~a)(i~circle~a~_F))(i~landuse~s~!(i~hidden~a)(i~default~a~_F)))(i~roadClassification~topics~!(i~roadClassification*_legacy~s~!(i~hidden~a~_F)(i~default~a)(i~oneway~a~_F))(i~bikelanes~s~!(i~hidden~a)(i~default~a~_F)(i~verification~a~_F)(i~completeness~a~_F))(i~maxspeed*_legacy~s~!(i~hidden~a)(i~default~a~_F)(i~details~a~_F))(i~surfaceQuality*_legacy~s~!(i~hidden~a)(i~default~a~_F)(i~bad~a~_F)(i~completeness~a~_F)(i~freshness~a~_F))(i~places~s~!(i~hidden~a~_F)(i~default~a)(i~circle~a~_F))(i~landuse~s~!(i~hidden~a)(i~default~a~_F)))(i~lit~topics~!(i~lit*_legacy~s~!(i~hidden~a~_F)(i~default~a)(i~completeness~a~_F)(i~verification~a~_F)(i~freshness~a~_F))(i~places~s~!(i~hidden~a)(i~default~a~_F)(i~circle~a~_F))(i~landuse~s~!(i~hidden~a)(i~default~a~_F)))~' ) const search = renderQueryString(url.searchParams) - expect(search).toBe(url.search) + expect(search).toBe( + '?bg=default&config=!(i~fromTo~topics~!(i~shops~s~!(i~hidden~a~_F)(i~default~a))(i~education~s~!(i~hidden~a)(i~default~a~_F))(i~places~s~!(i~hidden~a~_F)(i~default~a)(i~circle~a~_F))(i~buildings~s~!(i~hidden~a)(i~default~a~_F))(i~landuse~s~!(i~hidden~a~_F)(i~default~a))(i~barriers~s~!(i~hidden~a~_F)(i~default~a))(i~boundaries~s~!(i~hidden~a)(i~default~a~_F)(i~level-8~a~_F)(i~level-9-10~a~_F)))(i~bikelanes~topics~!(i~bikelanes~s~!(i~hidden~a~_F)(i~default~a)(i~verification~a~_F)(i~completeness~a~_F))(i~bikelanesPresence*_legacy~s~!(i~hidden~a)(i~default~a~_F))(i~places~s~!(i~hidden~a~_F)(i~default~a)(i~circle~a~_F))(i~landuse~s~!(i~hidden~a)(i~default~a~_F)))(i~roadClassification~topics~!(i~roadClassification*_legacy~s~!(i~hidden~a~_F)(i~default~a)(i~oneway~a~_F))(i~bikelanes~s~!(i~hidden~a)(i~default~a~_F)(i~verification~a~_F)(i~completeness~a~_F))(i~maxspeed*_legacy~s~!(i~hidden~a)(i~default~a~_F)(i~details~a~_F))(i~surfaceQuality*_legacy~s~!(i~hidden~a)(i~default~a~_F)(i~bad~a~_F)(i~completeness~a~_F)(i~freshness~a~_F))(i~places~s~!(i~hidden~a~_F)(i~default~a)(i~circle~a~_F))(i~landuse~s~!(i~hidden~a)(i~default~a~_F)))(i~lit~topics~!(i~lit*_legacy~s~!(i~hidden~a~_F)(i~default~a)(i~completeness~a~_F)(i~verification~a~_F)(i~freshness~a~_F))(i~places~s~!(i~hidden~a)(i~default~a~_F)(i~circle~a~_F))(i~landuse~s~!(i~hidden~a)(i~default~a~_F)))~&lat=53.6774&lng=13.267&theme=fromTo&zoom=10.6' + ) } }) test('keys with special characters get escaped', () => { From 71c84ccbc61f406edb19752136817aecb907b16f Mon Sep 17 00:00:00 2001 From: Hugo Tiger <49451774+hugotiger@users.noreply.github.com> Date: Mon, 16 Sep 2024 13:12:39 +0200 Subject: [PATCH 33/34] doc: Add note about sorting of query string keys --- packages/docs/content/docs/seo.mdx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/docs/content/docs/seo.mdx b/packages/docs/content/docs/seo.mdx index ff01d082f..0946f952c 100644 --- a/packages/docs/content/docs/seo.mdx +++ b/packages/docs/content/docs/seo.mdx @@ -45,3 +45,10 @@ export async function generateMetadata({ } } ``` + +By default, query strings are sorted by their keys to prevent SEO issues related to variations in query string order. +The following example will serialize the query string as `?apple=foo&banana=foo`: + +```tsx + useQueryState({ banana: 'foo', apple: 'foo' }) +``` \ No newline at end of file From bc05ce5ca9bf817674220cccb750406d291481c6 Mon Sep 17 00:00:00 2001 From: Hugo Tiger <49451774+hugotiger@users.noreply.github.com> Date: Mon, 16 Sep 2024 13:46:50 +0200 Subject: [PATCH 34/34] test: Update failing tests --- packages/e2e/cypress/e2e/persist-across-navigation.cy.js | 2 +- packages/e2e/cypress/e2e/push.cy.js | 8 ++++---- packages/e2e/cypress/e2e/repro-359.cy.js | 8 ++++---- packages/e2e/cypress/e2e/useSearchParams.cy.js | 2 +- packages/nuqs/src/serializer.test.ts | 4 ++-- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/e2e/cypress/e2e/persist-across-navigation.cy.js b/packages/e2e/cypress/e2e/persist-across-navigation.cy.js index 57358ff77..2cadb01d9 100644 --- a/packages/e2e/cypress/e2e/persist-across-navigation.cy.js +++ b/packages/e2e/cypress/e2e/persist-across-navigation.cy.js @@ -10,7 +10,7 @@ it('Persists search params across navigation using a generated Link href', () => 'eq', `${Cypress.env('basePath')}/app/persist-across-navigation/b` ) - cy.location('search').should('eq', '?q=foo&checked=true') + cy.location('search').should('eq', '?checked=true&q=foo') cy.get('input[type=text]').should('have.value', 'foo') cy.get('input[type=checkbox]').should('be.checked') }) diff --git a/packages/e2e/cypress/e2e/push.cy.js b/packages/e2e/cypress/e2e/push.cy.js index 37513dba9..5ee2e4298 100644 --- a/packages/e2e/cypress/e2e/push.cy.js +++ b/packages/e2e/cypress/e2e/push.cy.js @@ -15,7 +15,7 @@ describe('push', () => { cy.get('#client').should('have.text', '0') cy.get('button#client-incr').click() - cy.location('search').should('eq', '?server=1&client=1') + cy.location('search').should('eq', '?client=1&server=1') cy.get('#server-side').should('have.text', '1') cy.get('#server').should('have.text', '1') cy.get('#client').should('have.text', '1') @@ -39,7 +39,7 @@ describe('push', () => { cy.get('#client').should('have.text', '0') cy.go('forward') - cy.location('search').should('eq', '?server=1&client=1') + cy.location('search').should('eq', '?client=1&server=1') cy.get('#server-side').should('have.text', '1') cy.get('#server').should('have.text', '1') cy.get('#client').should('have.text', '1') @@ -58,7 +58,7 @@ describe('push', () => { cy.get('#client').should('have.text', '0') cy.get('button#client-incr').click() - cy.location('search').should('eq', '?server=1&client=1') + cy.location('search').should('eq', '?client=1&server=1') cy.get('#server-side').should('have.text', '1') cy.get('#server').should('have.text', '1') cy.get('#client').should('have.text', '1') @@ -82,7 +82,7 @@ describe('push', () => { cy.get('#client').should('have.text', '0') cy.go('forward') - cy.location('search').should('eq', '?server=1&client=1') + cy.location('search').should('eq', '?client=1&server=1') cy.get('#server-side').should('have.text', '1') cy.get('#server').should('have.text', '1') cy.get('#client').should('have.text', '1') diff --git a/packages/e2e/cypress/e2e/repro-359.cy.js b/packages/e2e/cypress/e2e/repro-359.cy.js index a9ed26cb6..6ab5cb582 100644 --- a/packages/e2e/cypress/e2e/repro-359.cy.js +++ b/packages/e2e/cypress/e2e/repro-359.cy.js @@ -12,7 +12,7 @@ it('repro-359', () => { cy.contains('Component 1 (nuqs)').click() // cy.wait(100) - cy.location('search').should('eq', '?param=comp1&component=comp1') + cy.location('search').should('eq', '?component=comp1¶m=comp1') cy.get('#comp1').should('have.text', 'comp1') cy.get('#comp2').should('not.exist') cy.get('#nuqs-param').should('have.text', 'comp1') @@ -22,7 +22,7 @@ it('repro-359', () => { cy.contains('Component 2 (nuqs)').click() // cy.wait(100) - cy.location('search').should('eq', '?param=comp2&component=comp2') + cy.location('search').should('eq', '?component=comp2¶m=comp2') cy.get('#comp1').should('not.exist') cy.get('#comp2').should('have.text', 'comp2') cy.get('#nuqs-param').should('have.text', 'comp2') @@ -32,7 +32,7 @@ it('repro-359', () => { cy.contains('Component 1 (nuq+)').click() // cy.wait(100) - cy.location('search').should('eq', '?param=comp1&component=comp1') + cy.location('search').should('eq', '?component=comp1¶m=comp1') cy.get('#comp1').should('have.text', 'comp1') cy.get('#comp2').should('not.exist') cy.get('#nuqs-param').should('have.text', 'comp1') @@ -42,7 +42,7 @@ it('repro-359', () => { cy.contains('Component 2 (nuq+)').click() // cy.wait(100) - cy.location('search').should('eq', '?param=comp2&component=comp2') + cy.location('search').should('eq', '?component=comp2¶m=comp2') cy.get('#comp1').should('not.exist') cy.get('#comp2').should('have.text', 'comp2') cy.get('#nuqs-param').should('have.text', 'comp2') diff --git a/packages/e2e/cypress/e2e/useSearchParams.cy.js b/packages/e2e/cypress/e2e/useSearchParams.cy.js index 1e2527352..a19b1e64b 100644 --- a/packages/e2e/cypress/e2e/useSearchParams.cy.js +++ b/packages/e2e/cypress/e2e/useSearchParams.cy.js @@ -7,6 +7,6 @@ if (Cypress.env('supportsShallowRouting')) { cy.get('input').type('foo', { delay: 0 }) cy.get('#searchParams').should('have.text', 'q=foo') cy.get('button').click() - cy.get('#searchParams').should('have.text', 'q=foo&push=true') + cy.get('#searchParams').should('have.text', 'push=true&q=foo') }) } diff --git a/packages/nuqs/src/serializer.test.ts b/packages/nuqs/src/serializer.test.ts index f90ad52e5..6c13b9b85 100644 --- a/packages/nuqs/src/serializer.test.ts +++ b/packages/nuqs/src/serializer.test.ts @@ -21,8 +21,8 @@ describe('serializer', () => { }) test('several items', () => { const serialize = createSerializer(parsers) - const result = serialize({ str: 'foo', int: 1, bool: true }) - expect(result).toBe('?str=foo&int=1&bool=true') + const result = serialize({ bool: true, int: 1, str: 'foo' }) + expect(result).toBe('?bool=true&int=1&str=foo') }) test("null items don't show up", () => { const serialize = createSerializer(parsers)