diff --git a/errors/NUQS-303.md b/errors/NUQS-303.md index 59a76a1d4..b4c060833 100644 --- a/errors/NUQS-303.md +++ b/errors/NUQS-303.md @@ -2,16 +2,14 @@ ## Probable cause -This error occurs in [debug mode](https://nuqs.dev/docs/debugging) in -certain monorepo setups where references of the adapter context aren't the same -in different packages, and cause a [`NUQS-404 - nuqs requires an adapter to work with your framework`](./NUQS-404.md) error. - -## Root cause - -As described in the [React docs](https://react.dev/reference/react/useContext#my-component-doesnt-see-the-value-from-my-provider), this can happen with Context providers (which -is what adapters are) being re-created in different modules and causing different -references being used for a provider and consumers. +Parts of your application are using different versions of `nuqs` or React, so +they cannot use the same adapter. ## Possible solutions -See issue [#798](https://github.com/47ng/nuqs/issues/798) for more details. +Make sure all packages use the same version of `nuqs`. If your application has +multiple React roots, wrap each one with the appropriate `NuqsAdapter`. + +Multiple copies of the same `nuqs` version are supported by the fix in +[#1469](https://github.com/47ng/nuqs/pull/1469). If needed, upgrade to a release +that includes it. diff --git a/errors/NUQS-404.md b/errors/NUQS-404.md index 64696ace9..57b63ae55 100644 --- a/errors/NUQS-404.md +++ b/errors/NUQS-404.md @@ -32,15 +32,10 @@ setup/assertion testing facilities. ### Monorepo setups -This error can also occur in monorepo setups where components using nuqs hooks -are in different packages resolving to different `nuqs` versions, -leading to different context references being used. - -If you [enable debugging](https://nuqs.dev/docs/debugging), you might see a -[`NUQS-303 - Multiple adapter contexts detected`](./NUQS-303.md) error, confirming -this hypothesis. - -Make sure that all packages resolve to the same version -of `nuqs` to prevent this issue from arising. See issue -[#798](https://github.com/47ng/nuqs/issues/798) for more details and -possible solutions. +Components using nuqs can live in workspace or shared packages. Make sure they +are rendered below the application's `NuqsAdapter` and that all packages use the +same version of `nuqs`. + +Multiple copies of the same version are supported by the fix in +[#1469](https://github.com/47ng/nuqs/pull/1469). If every package already uses +the same version, upgrade to a release that includes it. diff --git a/errors/NUQS-409.md b/errors/NUQS-409.md index 7c76ecd82..2bd66cdd4 100644 --- a/errors/NUQS-409.md +++ b/errors/NUQS-409.md @@ -9,5 +9,10 @@ you are also using `nuqs` directly. ## Possible Solutions Inspect your dependencies for duplicate versions of `nuqs` and -use the `resolutions` field in `package.json` to force all dependencies -to use the same version. +use your package manager's overrides or resolutions to align them. + +If you publish a library that uses `nuqs`, declare it as a peer dependency and +exclude it from your bundle. + +[#1469](https://github.com/47ng/nuqs/pull/1469) adds support for multiple copies +of the same version. Different versions still need to be aligned. diff --git a/packages/nuqs/package.json b/packages/nuqs/package.json index 90a02c684..f0d8fb0fb 100644 --- a/packages/nuqs/package.json +++ b/packages/nuqs/package.json @@ -201,6 +201,7 @@ "typescript": "catalog:typescript", "typescript-7": "catalog:typescript", "valibot": "^1.2.0", + "vite": "catalog:vite", "vitest": "catalog:vitest", "vitest-browser-react": "2.0.4", "zod": "^4.3.6" diff --git a/packages/nuqs/scripts/prepack.sh b/packages/nuqs/scripts/prepack.sh index 7d5751820..69d64a98a 100755 --- a/packages/nuqs/scripts/prepack.sh +++ b/packages/nuqs/scripts/prepack.sh @@ -11,6 +11,13 @@ cp -f ../../README.md ../../LICENSE ./ # Read the version from package.json VERSION=$(jq -r '.version' < package.json) +# The placeholder feeds the globalThis singleton keys shared across +# duplicate copies: failing to inject the version would make different +# published versions collide on the same keys. +if ! grep -rq "0.0.0-inject-version-here" dist --include="*.js"; then + echo "Error: version placeholder not found in dist output" >&2 + exit 1 +fi if [[ "$(uname)" == "Darwin" ]]; then # macOS requires an empty string as the backup extension diff --git a/packages/nuqs/scripts/prepack.test.ts b/packages/nuqs/scripts/prepack.test.ts new file mode 100644 index 000000000..0392ba1c0 --- /dev/null +++ b/packages/nuqs/scripts/prepack.test.ts @@ -0,0 +1,81 @@ +import { spawnSync } from 'node:child_process' +import { + chmod, + copyFile, + mkdir, + mkdtemp, + readFile, + rm, + writeFile +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { delimiter, join } from 'node:path' +import { expect, it } from 'vitest' + +it('prepares the package when any output contains the version placeholder', async () => { + const fixtureRoot = await mkdtemp(join(tmpdir(), 'nuqs-prepack-')) + const repoRoot = join(fixtureRoot, 'repo') + const packageDir = join(repoRoot, 'packages', 'nuqs') + const scriptsDir = join(packageDir, 'scripts') + const distDir = join(packageDir, 'dist') + const binDir = join(fixtureRoot, 'bin') + try { + await Promise.all([ + mkdir(scriptsDir, { recursive: true }), + mkdir(distDir, { recursive: true }), + mkdir(binDir, { recursive: true }) + ]) + await Promise.all([ + copyFile( + new URL('./prepack.sh', import.meta.url), + join(scriptsDir, 'prepack.sh') + ), + writeFile(join(repoRoot, 'README.md'), '# Fixture'), + writeFile(join(repoRoot, 'LICENSE'), 'MIT'), + writeFile( + join(packageDir, 'package.json'), + JSON.stringify({ version: '1.2.3' }) + ), + writeFile( + join(distDir, 'with-placeholder.js'), + 'export const version = "0.0.0-inject-version-here"\n' + ), + writeFile( + join(distDir, 'without-placeholder.js'), + 'export const answer = 42\n' + ) + ]) + + const fakeFind = join(binDir, 'find') + await writeFile( + fakeFind, + [ + '#!/usr/bin/env bash', + 'if [[ "$*" == *"grep -q"* ]]; then', + ' exit 1', + 'fi', + 'exec /usr/bin/find "$@"' + ].join('\n') + ) + await Promise.all([ + chmod(join(scriptsDir, 'prepack.sh'), 0o755), + chmod(fakeFind, 0o755) + ]) + + const result = spawnSync(join(scriptsDir, 'prepack.sh'), { + cwd: packageDir, + encoding: 'utf8', + env: { + ...process.env, + PATH: binDir + delimiter + process.env.PATH + } + }) + + expect(result.status, result.stderr).toBe(0) + await expect( + readFile(join(distDir, 'with-placeholder.js'), 'utf8') + ).resolves.toContain('1.2.3') + } finally { + await rm(fixtureRoot, { recursive: true, force: true }) + } +}) diff --git a/packages/nuqs/src/adapters/lib/context.test.ts b/packages/nuqs/src/adapters/lib/context.test.ts new file mode 100644 index 000000000..2955dd429 --- /dev/null +++ b/packages/nuqs/src/adapters/lib/context.test.ts @@ -0,0 +1,24 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { version } from '../../lib/version' + +describe('adapter context keying across React instances', () => { + afterEach(() => { + vi.doUnmock('react') + vi.resetModules() + const registry = globalThis as { [key: symbol]: unknown } + delete registry[Symbol.for(`nuqs.${version}.adapter-context`)] + }) + + it('keeps contexts isolated across distinct createContext identities', async () => { + const real = await import('./context') + vi.resetModules() + vi.doMock('react', async importOriginal => { + const actual = await importOriginal() + const createContext: typeof actual.createContext = defaultValue => + actual.createContext(defaultValue) + return { ...actual, createContext } + }) + const other = await import('./context') + expect(other.context).not.toBe(real.context) + }) +}) diff --git a/packages/nuqs/src/adapters/lib/context.ts b/packages/nuqs/src/adapters/lib/context.ts index 4c6028b58..1cc3dd3db 100644 --- a/packages/nuqs/src/adapters/lib/context.ts +++ b/packages/nuqs/src/adapters/lib/context.ts @@ -9,6 +9,7 @@ import { } from 'react' import type { Options } from '../../defs' import { error } from '../../lib/errors' +import { globalWeakSingleton } from '../../lib/global-singleton' import type { AdapterInterface, UseAdapterHook } from './defs' export type AdapterProps = { @@ -25,12 +26,24 @@ export type AdapterContext = AdapterProps & { useAdapter: UseAdapterHook } -export const context: Context = createContext({ - useAdapter() { - throw new Error(error(404)) +// Keyed by createContext identity: copies sharing one React instance share +// the context, while distinct React instances keep isolated contexts. +// Revisit in nuqs@3 (react@^19 only): the React 18/19 Provider shape hazard +// goes away, but distinct React instances on one page would then share one +// context object (concurrent renders interleave its _currentValue). +export const context: Context = globalWeakSingleton( + 'adapter-context', + createContext, + () => { + const ctx = createContext({ + useAdapter() { + throw new Error(error(404)) + } + }) + ctx.displayName = 'NuqsAdapterContext' + return ctx } -}) -context.displayName = 'NuqsAdapterContext' +) declare global { interface Window { @@ -38,7 +51,9 @@ declare global { } } -// Detect multiple adapter contexts (e.g. duplicate nuqs copies in a monorepo). +// Detect adapter contexts that cannot be shared across duplicate copies: +// nuqs version mismatch, or multiple React instances. Same-version copies +// on one React share a single context via globalWeakSingleton above. if (typeof window !== 'undefined') { if (window.__NuqsAdapterContext && window.__NuqsAdapterContext !== context) { console.error(error(303)) diff --git a/packages/nuqs/src/adapters/lib/patch-history.ts b/packages/nuqs/src/adapters/lib/patch-history.ts index b622f0ded..c7aaaec7b 100644 --- a/packages/nuqs/src/adapters/lib/patch-history.ts +++ b/packages/nuqs/src/adapters/lib/patch-history.ts @@ -1,11 +1,21 @@ import { debug } from '../../lib/debug' -import type { Emitter } from '../../lib/emitter' +import { createEmitter, type Emitter } from '../../lib/emitter' import { error } from '../../lib/errors' +import { globalSingleton } from '../../lib/global-singleton' import { resetQueues, spinQueueResetMutex } from '../../lib/queues/reset' import { getSearchParams } from '../../lib/search-params' +import { version } from '../../lib/version' export type SearchParamsSyncEmitterEvents = { update: URLSearchParams } +export function getHistorySyncEmitter( + adapter: string +): Emitter { + return globalSingleton(`history-emitter.${adapter}`, () => + createEmitter() + ) +} + export const historyUpdateMarker = '__nuqs__' declare global { @@ -21,16 +31,8 @@ export function shouldPatchHistory(adapter: string): boolean { if (typeof history === 'undefined') { return false } - if ( - history.nuqs?.version && - history.nuqs.version !== '0.0.0-inject-version-here' - ) { - console.error( - error(409), - history.nuqs.version, - `0.0.0-inject-version-here`, - adapter - ) + if (history.nuqs?.version && history.nuqs.version !== version) { + console.error(error(409), history.nuqs.version, version, adapter) return false } if (history.nuqs?.adapters?.includes(adapter)) { @@ -41,8 +43,7 @@ export function shouldPatchHistory(adapter: string): boolean { export function markHistoryAsPatched(adapter: string): void { history.nuqs = history.nuqs ?? { - // This will be replaced by the prepack script - version: '0.0.0-inject-version-here', + version, adapters: [] } history.nuqs.adapters.push(adapter) @@ -67,7 +68,7 @@ export function patchHistory( resetQueues() }) - debug(21, '0.0.0-inject-version-here', adapter) + debug(21, version, adapter) function sync(url: URL | string) { spinQueueResetMutex() try { diff --git a/packages/nuqs/src/adapters/lib/react-router.ts b/packages/nuqs/src/adapters/lib/react-router.ts index db2bdffc9..d2e366ff0 100644 --- a/packages/nuqs/src/adapters/lib/react-router.ts +++ b/packages/nuqs/src/adapters/lib/react-router.ts @@ -1,15 +1,14 @@ import { startTransition, useCallback, useEffect, useState } from 'react' import { debug } from '../../lib/debug' -import { createEmitter } from '../../lib/emitter' import { setQueueResetMutex } from '../../lib/queues/reset' import { renderQueryString } from '../../lib/url-encoding' import { createAdapterProvider, type AdapterProvider } from './context' import type { AdapterInterface, AdapterOptions } from './defs' import { applyChange, filterSearchParams } from './key-isolation' import { - patchHistory as applyHistoryPatch, + getHistorySyncEmitter, historyUpdateMarker, - type SearchParamsSyncEmitterEvents + patchHistory as applyHistoryPatch } from './patch-history' // Abstract away the types for the useNavigate hook from react-router-based frameworks @@ -42,7 +41,7 @@ export function createReactRouterBasedAdapter({ NuqsAdapter: AdapterProvider useOptimisticSearchParams: () => URLSearchParams } { - const emitter = createEmitter() + const emitter = getHistorySyncEmitter(adapter) function useNuqsReactRouterBasedAdapter( watchKeys: string[] ): AdapterInterface { diff --git a/packages/nuqs/src/adapters/next/impl.pages.ts b/packages/nuqs/src/adapters/next/impl.pages.ts index 30829c331..19f1a38d7 100644 --- a/packages/nuqs/src/adapters/next/impl.pages.ts +++ b/packages/nuqs/src/adapters/next/impl.pages.ts @@ -2,6 +2,7 @@ import { useRouter } from 'next/compat/router.js' import type { NextRouter } from 'next/router' import { useCallback, useEffect, useMemo } from 'react' import { debug } from '../../lib/debug' +import { globalSingleton } from '../../lib/global-singleton' import { resetQueues } from '../../lib/queues/reset' import { renderQueryString } from '../../lib/url-encoding' import type { AdapterInterface, UpdateUrlFunction } from '../lib/defs' @@ -22,10 +23,12 @@ export function isPagesRouter(): boolean { return typeof window.next?.router?.state?.asPath === 'string' } -let isNuqsUpdateMutex: boolean = false +const updateState = globalSingleton('next-pages-router-update', () => ({ + isNuqsUpdate: false +})) function onNavigation() { - if (isNuqsUpdateMutex) { + if (updateState.isNuqsUpdate) { return } resetQueues() @@ -77,35 +80,40 @@ export function useNuqsNextPagesRouterAdapter(): AdapterInterface { debug(20, 'next/pages', asPath) const method = options.history === 'push' ? nextRouter.push : nextRouter.replace - isNuqsUpdateMutex = true - method - .call( - nextRouter, - // This is what makes the URL work (mapping dynamic segments placeholders - // in pathname to their values in query, plus search params in query too). - { - pathname: nextRouter.pathname, - query: { - // Note: we put search params first so that one that conflicts - // with dynamic params will be overwritten. - ...urlSearchParamsToObject(search), - ...urlParams + updateState.isNuqsUpdate = true + try { + method + .call( + nextRouter, + // This is what makes the URL work (mapping dynamic segments placeholders + // in pathname to their values in query, plus search params in query too). + { + pathname: nextRouter.pathname, + query: { + // Note: we put search params first so that one that conflicts + // with dynamic params will be overwritten. + ...urlSearchParamsToObject(search), + ...urlParams + } + // For some reason we don't need to pass the hash here, + // it's preserved when passed as part of the asPath. + }, + // This is what makes the URL pretty (resolved dynamic segments + // and nuqs-formatted search params). + asPath, + // And these are the options that are passed to the router. + { + scroll: options.scroll, + shallow: options.shallow } - // For some reason we don't need to pass the hash here, - // it's preserved when passed as part of the asPath. - }, - // This is what makes the URL pretty (resolved dynamic segments - // and nuqs-formatted search params). - asPath, - // And these are the options that are passed to the router. - { - scroll: options.scroll, - shallow: options.shallow - } - ) - .finally(() => { - isNuqsUpdateMutex = false - }) + ) + .finally(() => { + updateState.isNuqsUpdate = false + }) + } catch (error) { + updateState.isNuqsUpdate = false + throw error + } }, []) return { diff --git a/packages/nuqs/src/adapters/react.ts b/packages/nuqs/src/adapters/react.ts index 3c25a1388..c5a9eb182 100644 --- a/packages/nuqs/src/adapters/react.ts +++ b/packages/nuqs/src/adapters/react.ts @@ -9,18 +9,17 @@ import { type ReactNode } from 'react' import { debug } from '../lib/debug' -import { createEmitter } from '../lib/emitter' import { renderQueryString } from '../lib/url-encoding' import { createAdapterProvider, type AdapterProps } from './lib/context' import type { AdapterInterface, AdapterOptions } from './lib/defs' import { filterSearchParams } from './lib/key-isolation' import { + getHistorySyncEmitter, historyUpdateMarker, - patchHistory, - type SearchParamsSyncEmitterEvents + patchHistory } from './lib/patch-history' -const emitter = createEmitter() +const emitter = getHistorySyncEmitter('react') function generateUpdateUrlFn(fullPageNavigationOnShallowFalseUpdates: boolean) { return function updateUrl(search: URLSearchParams, options: AdapterOptions) { diff --git a/packages/nuqs/src/duplicate-copies.browser.test.tsx b/packages/nuqs/src/duplicate-copies.browser.test.tsx new file mode 100644 index 000000000..4184f5465 --- /dev/null +++ b/packages/nuqs/src/duplicate-copies.browser.test.tsx @@ -0,0 +1,392 @@ +import React from 'react' +import { describe, expect, it, vi } from 'vitest' +import { page } from 'vitest/browser' +import { render } from 'vitest-browser-react' +import * as nextPagesAdapterA from './adapters/next/pages' +import * as reactAdapterA from './adapters/react' +import * as adapterA from './adapters/testing' +import * as nuqsA from './index' +import * as nuqsB from 'nuqs-copy-b' +import * as nextPagesAdapterB from 'nuqs-copy-b/adapters/next/pages' +import * as reactAdapterB from 'nuqs-copy-b/adapters/react' + +const pagesRouterHarness = vi.hoisted(() => { + type Listener = () => void + const listeners = new Map>() + let nextUpdateError: Error | null = null + function emit(event: string) { + for (const listener of listeners.get(event) ?? []) { + listener() + } + } + function update() { + if (nextUpdateError !== null) { + const error = nextUpdateError + nextUpdateError = null + throw error + } + emit('routeChangeStart') + emit('beforeHistoryChange') + return Promise.resolve(true) + } + const router = { + asPath: '/', + pathname: '/', + query: {}, + state: { asPath: '/' }, + events: { + on(event: string, listener: Listener) { + const eventListeners = listeners.get(event) ?? new Set() + eventListeners.add(listener) + listeners.set(event, eventListeners) + }, + off(event: string, listener: Listener) { + listeners.get(event)?.delete(listener) + } + }, + push: update, + replace: update + } + return { + router, + failNextUpdate(error: Error) { + nextUpdateError = error + }, + navigate() { + emit('routeChangeStart') + emit('beforeHistoryChange') + }, + reset() { + listeners.clear() + nextUpdateError = null + } + } +}) + +vi.mock('next/compat/router.js', () => { + const useRouter = () => pagesRouterHarness.router + return { default: { useRouter }, useRouter } +}) + +// nuqsB is a second, independent instance of the library source graph +// (see the duplicateLibraryCopy plugin in vitest.config.ts), simulating +// a monorepo loading two physical copies of nuqs (issue #798): +// copy A provides the adapter, copy B consumes the hooks. + +describe('duplicate library copies', () => { + it('loads distinct module instances (harness self-check)', () => { + expect(nuqsB.useQueryState).not.toBe(nuqsA.useQueryState) + }) + + it('shares the adapter context across copies', async () => { + function Demo() { + const [q] = nuqsB.useQueryState('q') + return {q} + } + render(, { + wrapper: adapterA.withNuqsTestingAdapter({ searchParams: '?q=hello' }) + }) + await expect.element(page.getByTestId('q')).toHaveTextContent('hello') + }) + + it('syncs external history updates with adapters from both copies', async () => { + const originalUrl = location.href + const originalPushState = history.pushState + const originalReplaceState = history.replaceState + originalReplaceState.call(history, null, '', '?q=hello') + reactAdapterA.enableHistorySync() + reactAdapterB.enableHistorySync() + function DemoA() { + const [q] = nuqsA.useQueryState('q') + return {q} + } + function DemoB() { + const [q] = nuqsB.useQueryState('q') + return {q} + } + try { + render( + <> + + + + + + + + ) + await expect + .element(page.getByTestId('history-a')) + .toHaveTextContent('hello') + await expect + .element(page.getByTestId('history-b')) + .toHaveTextContent('hello') + + history.pushState(null, '', '?q=external') + + await expect + .element(page.getByTestId('history-a')) + .toHaveTextContent('external') + await expect + .element(page.getByTestId('history-b')) + .toHaveTextContent('external') + } finally { + history.pushState = originalPushState + history.replaceState = originalReplaceState + delete history.nuqs + originalReplaceState.call(history, null, '', originalUrl) + } + }) + + it('syncs state updates across copies', async () => { + function DemoA() { + const [q] = nuqsA.useQueryState('q') + return {q} + } + function DemoB() { + const [q, setQ] = nuqsB.useQueryState('q') + return ( + + ) + } + render( + <> + + + , + { + wrapper: adapterA.withNuqsTestingAdapter({ searchParams: '?q=hello' }) + } + ) + await page.getByTestId('b').click() + await expect.element(page.getByTestId('b')).toHaveTextContent('world') + await expect.element(page.getByTestId('a')).toHaveTextContent('world') + }) + + it("does not abort another copy's Pages Router update", async () => { + const originalNext = window.next + window.next = { router: pagesRouterHarness.router as never } + let updatePromise: Promise | undefined + function DemoA() { + const [, setQ] = nuqsA.useQueryState('q') + return ( +