Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/nuqs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
7 changes: 7 additions & 0 deletions packages/nuqs/scripts/prepack.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 ! find dist -name "*.js" -exec grep -q "0.0.0-inject-version-here" {} + ; then
echo "Error: version placeholder not found in dist output" >&2
exit 1
fi

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Demonstrate the batching hazard: find splits -exec into multiple grep calls,
# and a batch without the match makes find (and the guard) fail.
mkdir -p /tmp/distcheck
printf 'const version="0.0.0-inject-version-here"\n' > /tmp/distcheck/a.js
printf 'no placeholder here\n' > /tmp/distcheck/b.js
# Force a separate invocation per file to mimic ARG_MAX splitting:
if ! find /tmp/distcheck -name "*.js" -exec grep -q "0.0.0-inject-version-here" {} \; ; then
  echo "GUARD FAILED even though placeholder exists (batching hazard)"
else
  echo "guard passed"
fi
# Robust alternative:
grep -rq "0.0.0-inject-version-here" /tmp/distcheck --include="*.js" && echo "grep -rq matched"

Repository: 47ng/nuqs

Length of output: 177


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the relevant script lines.
sed -n '1,80p' packages/nuqs/scripts/prepack.sh

# Behavioural probe: create enough long paths to encourage find to batch -exec.
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT
mkdir -p "$tmpdir/dist"

# One file contains the placeholder; the rest do not.
printf '%s\n' 'const v="0.0.0-inject-version-here"' > "$tmpdir/dist/match.js"

# Create many long-named JS files to make argument batching more likely.
python3 - <<'PY' "$tmpdir/dist"
import os, sys
dist = sys.argv[1]
name = "x" * 180 + ".js"
for i in range(2500):
    with open(os.path.join(dist, f"{i:04d}-{name}"), "w") as f:
        f.write("no match\n")
PY

# Show how many invocations find makes and whether the guard can fail.
count=0
if ! find "$tmpdir/dist" -name "*.js" -exec sh -c '
  count_file="$1/.count"
  n=0
  [ -f "$count_file" ] && n="$(cat "$count_file")"
  n=$((n+1))
  printf "%s\n" "$n" > "$count_file"
  exec grep -q "0.0.0-inject-version-here" "$@"
' sh "$tmpdir/dist" {} +; then
  echo "guard failed"
else
  echo "guard passed"
fi
echo "invocations: $(cat "$tmpdir/dist/.count" 2>/dev/null || echo 0)"

Repository: 47ng/nuqs

Length of output: 1341


🏁 Script executed:

#!/bin/bash
set -euo pipefail

tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT
mkdir -p "$tmpdir/dist"

# One matching file, many non-matching JS files to encourage batching.
printf '%s\n' 'const v="0.0.0-inject-version-here"' > "$tmpdir/dist/match.js"
for i in $(seq 1 3000); do
  printf '%s\n' "no match $i" > "$tmpdir/dist/$(printf '%04d' "$i").js"
done

# Count how many times find invokes the command and record whether any batch matches.
count_file="$tmpdir/count"
touch "$count_file"

if find "$tmpdir/dist" -name "*.js" -exec sh -c '
  count_file="$1"
  shift
  n=$(cat "$count_file")
  n=$((n + 1))
  printf "%s\n" "$n" > "$count_file"
  grep -q "0.0.0-inject-version-here" "$@"
' sh "$count_file" {} +; then
  echo "find exit: 0"
else
  echo "find exit: non-zero"
fi

echo "invocations: $(cat "$count_file")"

Repository: 47ng/nuqs

Length of output: 213


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n --hidden --glob '!**/node_modules/**' '0\.0\.0-inject-version-here|globalThis singleton keys|singleton keys' .

Repository: 47ng/nuqs

Length of output: 712


🏁 Script executed:

#!/bin/bash
set -euo pipefail

tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT
mkdir -p "$tmpdir/dist"

# One matching file and many long non-matching JS files to force batching.
printf '%s\n' 'const v="0.0.0-inject-version-here"' > "$tmpdir/dist/0000-match.js"
longname="$(printf 'x%.0s' {1..180})"
for i in {1..4000}; do
  : > "$tmpdir/dist/$(printf '%04d' "$i")-$longname.js"
done

count_file="$tmpdir/count"

if find "$tmpdir/dist" -name "*.js" -exec sh -c '
  count_file="$1"
  shift
  n=0
  [ -f "$count_file" ] && n="$(cat "$count_file")"
  n=$((n + 1))
  printf "%s\n" "$n" > "$count_file"
  grep -q "0.0.0-inject-version-here" "$@"
' sh "$count_file" {} +; then
  echo "find exit: 0"
else
  echo "find exit: non-zero"
fi

echo "invocations: $(cat "$count_file" 2>/dev/null || echo 0)"

Repository: 47ng/nuqs

Length of output: 182


Guard can fail spuriously when find splits -exec across batches. grep -q returns 1 for any batch that doesn’t contain the placeholder, and find propagates that non-zero status even if another batch matched. A larger dist tree can therefore break this release check; use grep -rq "0.0.0-inject-version-here" dist --include="*.js" instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/nuqs/scripts/prepack.sh` around lines 17 - 20, Replace the
find-based placeholder check with a recursive grep command using `grep -rq
"0.0.0-inject-version-here" dist --include="*.js"` in the prepack guard,
preserving the existing error message and exit behavior.


if [[ "$(uname)" == "Darwin" ]]; then
# macOS requires an empty string as the backup extension
Expand Down
24 changes: 24 additions & 0 deletions packages/nuqs/src/adapters/lib/context.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('react')>()
const createContext: typeof actual.createContext = defaultValue =>
actual.createContext(defaultValue)
return { ...actual, createContext }
})
const other = await import('./context')
expect(other.context).not.toBe(real.context)
})
})
27 changes: 21 additions & 6 deletions packages/nuqs/src/adapters/lib/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -25,20 +26,34 @@ export type AdapterContext = AdapterProps & {
useAdapter: UseAdapterHook
}

export const context: Context<AdapterContext> = createContext<AdapterContext>({
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<AdapterContext> = globalWeakSingleton(
'adapter-context',
createContext,
() => {
const ctx = createContext<AdapterContext>({
useAdapter() {
throw new Error(error(404))
}
})
ctx.displayName = 'NuqsAdapterContext'
return ctx
}
})
context.displayName = 'NuqsAdapterContext'
)

declare global {
interface Window {
__NuqsAdapterContext?: typeof context
}
}

// 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))
Expand Down
29 changes: 15 additions & 14 deletions packages/nuqs/src/adapters/lib/patch-history.ts
Original file line number Diff line number Diff line change
@@ -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<SearchParamsSyncEmitterEvents> {
return globalSingleton(`history-emitter.${adapter}`, () =>
createEmitter<SearchParamsSyncEmitterEvents>()
)
}

export const historyUpdateMarker = '__nuqs__'

declare global {
Expand All @@ -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)) {
Expand All @@ -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)
Expand All @@ -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 {
Expand Down
7 changes: 3 additions & 4 deletions packages/nuqs/src/adapters/lib/react-router.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -42,7 +41,7 @@ export function createReactRouterBasedAdapter({
NuqsAdapter: AdapterProvider
useOptimisticSearchParams: () => URLSearchParams
} {
const emitter = createEmitter<SearchParamsSyncEmitterEvents>()
const emitter = getHistorySyncEmitter(adapter)
function useNuqsReactRouterBasedAdapter(
watchKeys: string[]
): AdapterInterface {
Expand Down
11 changes: 7 additions & 4 deletions packages/nuqs/src/adapters/next/impl.pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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()
Expand Down Expand Up @@ -77,7 +80,7 @@ export function useNuqsNextPagesRouterAdapter(): AdapterInterface {
debug(20, 'next/pages', asPath)
const method =
options.history === 'push' ? nextRouter.push : nextRouter.replace
isNuqsUpdateMutex = true
updateState.isNuqsUpdate = true
method
.call(
nextRouter,
Expand All @@ -104,7 +107,7 @@ export function useNuqsNextPagesRouterAdapter(): AdapterInterface {
}
)
.finally(() => {
isNuqsUpdateMutex = false
updateState.isNuqsUpdate = false
})
}, [])

Expand Down
7 changes: 3 additions & 4 deletions packages/nuqs/src/adapters/react.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SearchParamsSyncEmitterEvents>()
const emitter = getHistorySyncEmitter('react')

function generateUpdateUrlFn(fullPageNavigationOnShallowFalseUpdates: boolean) {
return function updateUrl(search: URLSearchParams, options: AdapterOptions) {
Expand Down
Loading
Loading