Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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 knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"src/index.ts",
"src/index.server.ts",
"src/debug.ts",
"src/devtools.ts",
"src/testing.ts",
"src/adapters/react.ts",
"src/adapters/next.ts",
Expand Down
2 changes: 2 additions & 0 deletions packages/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/postcss": "^4.1.18",
"@tanstack/devtools-event-client": "0.4.4",
"@tanstack/react-devtools": "^0.10.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
Expand Down
2 changes: 2 additions & 0 deletions packages/docs/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { NuqsAdapter } from 'nuqs/adapters/next/app'
import { type ReactNode } from 'react'
import { TopBanner } from './banners'
import { Favicon } from '../components/favicon'
import { NuqsDevtoolsShell } from '../components/nuqs-devtools'
import { ResponsiveHelper } from '../components/responsive-helpers'
import { cn } from '../lib/utils'
import './globals.css'
Expand Down Expand Up @@ -76,6 +77,7 @@ export default function Layout({ children }: { children: ReactNode }) {
</>
)}
<ResponsiveHelper />
<NuqsDevtoolsShell />
</body>
</html>
)
Expand Down
15 changes: 15 additions & 0 deletions packages/docs/src/components/nuqs-devtools-impl.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
'use client'

import { TanStackDevtools } from '@tanstack/react-devtools'
import { NuqsDevtools } from 'nuqs/devtools'

// The actual devtools mount. Kept in its own module so the dev-only dynamic
// import in `nuqs-devtools.tsx` is the single reference to `nuqs/devtools` (and
// `@tanstack/react-devtools`), letting them tree-shake out of production.
// Importing `nuqs/devtools` installs the debug sink (always on in dev), so
// interacting with any page that uses nuqs streams its internals into the panel.
export default function NuqsDevtoolsImpl() {
return (
<TanStackDevtools plugins={[{ name: 'nuqs', render: <NuqsDevtools /> }]} />
)
}
12 changes: 12 additions & 0 deletions packages/docs/src/components/nuqs-devtools.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
'use client'

import dynamic from 'next/dynamic'

// Load the devtools (and, via the impl module, `nuqs/devtools` itself) only in
// development. In a production build `NODE_ENV` folds to the `() => null` branch,
// so the dynamic import is dead code and none of the panel / EventClient / sink
// ships in the deployed docs bundle.
export const NuqsDevtoolsShell =
process.env.NODE_ENV === 'production'
? () => null
: dynamic(() => import('./nuqs-devtools-impl'), { ssr: false })
13 changes: 12 additions & 1 deletion packages/nuqs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@
],
"type": "module",
"sideEffects": [
"./dist/debug.js"
"./dist/debug.js",
"./dist/devtools.js"
],
"module": "dist/index.js",
"types": "dist/index.d.ts",
Expand All @@ -69,6 +70,11 @@
"import": "./dist/debug.js",
"default": "./dist/debug.js"
},
"./devtools": {
"types": "./dist/devtools.d.ts",
"import": "./dist/devtools.js",
"default": "./dist/devtools.js"
},
"./server": {
"types": "./dist/server.d.ts",
"import": "./dist/server.js",
Expand Down Expand Up @@ -157,6 +163,7 @@
},
"peerDependencies": {
"@remix-run/react": ">=2",
"@tanstack/devtools-event-client": ">=0.4",
"@tanstack/react-router": "^1",
"next": ">=14.2.0",
"react": ">=18.2.0 || ^19.0.0-0",
Expand All @@ -167,6 +174,9 @@
"@remix-run/react": {
"optional": true
},
"@tanstack/devtools-event-client": {
"optional": true
},
"@tanstack/react-router": {
"optional": true
},
Expand All @@ -183,6 +193,7 @@
"devDependencies": {
"@remix-run/react": "^2.17.5",
"@size-limit/preset-small-lib": "^12.0.0",
"@tanstack/devtools-event-client": "0.4.4",
"@types/node": "^24.10.10",
"@types/react": "catalog:react19",
"@types/react-dom": "catalog:react19",
Expand Down
3 changes: 3 additions & 0 deletions packages/nuqs/src/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ const exports = `
"withNuqsTestingAdapter": "function",
},
"./debug": {},
"./devtools": {
"NuqsDevtools": "function",
},
"./server": {
"createLoader": "function",
"createMultiParser": "function",
Expand Down
4 changes: 2 additions & 2 deletions packages/nuqs/src/debug.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { isDebugFlagSet, setDebugSink } from './lib/debug'
import { addDebugSink, isDebugFlagSet } from './lib/debug'
import { debugMessages, sprintf } from './lib/debug-messages'

// Side-effect-only entry point (`nuqs/debug`): importing it opts client-side
Expand All @@ -18,7 +18,7 @@ import { debugMessages, sprintf } from './lib/debug-messages'
// process.env.DEBUG = 'nuqs' // on the server
// ```
function installDebugSink(): void {
setDebugSink((code, args, isWarn) => {
addDebugSink((code, args, isWarn) => {
const message = debugMessages[code]
if (isWarn) {
console.warn(message, ...args)
Expand Down
28 changes: 28 additions & 0 deletions packages/nuqs/src/devtools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { isDebugFlagSet } from './lib/debug'
import { installNuqsDevtoolsSink } from './lib/devtools/sink'

// Opt-in entry point (`nuqs/devtools`). Importing it (for the panel below, or as
// a bare side-effect import) installs a debug sink that forwards nuqs internals
// to a TanStack Devtools panel over the EventClient bus.
//
// Activation is client-only. In development it is always on; in production it
// rides the same `localStorage.debug=nuqs` flag as `nuqs/debug`. The EventClient
// queues then drops if no panel is mounted, so this is inert when the devtools
// aren't open.
//
// @example
// ```tsx
// import { TanStackDevtools } from '@tanstack/react-devtools'
// import { NuqsDevtools } from 'nuqs/devtools'
//
// <TanStackDevtools plugins={[{ name: 'nuqs', render: <NuqsDevtools /> }]} />
// ```
export { NuqsDevtools } from './lib/devtools/panel'
export type { NuqsLogEvent } from './lib/devtools/events'

if (
typeof window !== 'undefined' &&
(process.env.NODE_ENV !== 'production' || isDebugFlagSet())
) {
installNuqsDevtoolsSink()
}
42 changes: 41 additions & 1 deletion packages/nuqs/src/lib/debug.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,51 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { isDebugFlagSet } from './debug'
import { addDebugSink, debug, isDebugFlagSet, warn } from './debug'
import { sprintf } from './debug-messages'

afterEach(() => {
vi.unstubAllEnvs()
})

describe('debug/sink registry', () => {
it('does nothing and leaves args untouched when no sink is registered', () => {
const arg = { a: 1 }
expect(() => debug(6, 'id', 'key', arg)).not.toThrow()
expect(arg).toEqual({ a: 1 })
})

it('forwards code, args and warn flag to a registered sink', () => {
const sink = vi.fn()
const remove = addDebugSink(sink)
const cause = new Error('boom')
debug(6, 'id', 'key', { a: 1 })
warn(24, 'value', cause)
expect(sink).toHaveBeenNthCalledWith(1, 6, ['id', 'key', { a: 1 }])
expect(sink).toHaveBeenNthCalledWith(2, 24, ['value', cause], true)
remove()
})

it('fans out to every registered sink', () => {
const a = vi.fn()
const b = vi.fn()
const removeA = addDebugSink(a)
const removeB = addDebugSink(b)
debug(8)
expect(a).toHaveBeenCalledOnce()
expect(b).toHaveBeenCalledOnce()
removeA()
removeB()
})

it('stops calling a sink after its remover runs', () => {
const sink = vi.fn()
const remove = addDebugSink(sink)
debug(8)
remove()
debug(8)
expect(sink).toHaveBeenCalledOnce()
})
})

describe('debug/server (DEBUG env)', () => {
it('enables when DEBUG includes nuqs', () => {
vi.stubEnv('DEBUG', 'nuqs')
Expand Down
29 changes: 23 additions & 6 deletions packages/nuqs/src/lib/debug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,27 +9,44 @@ export type DebugSink = (
isWarn?: boolean
) => void

let sink: DebugSink | null = null
const sinks = new Set<DebugSink>()

/**
* Install (or remove, with `null`) the function that renders debug logs.
* Register a function that renders debug logs. Returns a remover.
*
* Multiple sinks can be active at once: the console logger from `nuqs/debug`
* and the TanStack Devtools bridge from `nuqs/devtools` register independently.
*/
export function setDebugSink(newSink: DebugSink | null): void {
sink = newSink
export function addDebugSink(sink: DebugSink): () => void {
sinks.add(sink)
return () => {
sinks.delete(sink)
}
}

export function debug<Code extends DebugCode>(
code: Code,
...args: DebugArgs<Code>
): void {
sink?.(code, args)
// Fast path when no sink is attached (the 99% case): never touch the args.
if (sinks.size === 0) {
return
}
for (const sink of sinks) {
sink(code, args)
}
}

export function warn<Code extends DebugCode>(
code: Code,
...args: DebugArgs<Code>
): void {
sink?.(code, args, true)
if (sinks.size === 0) {
return
}
for (const sink of sinks) {
sink(code, args, true)
}
}

export function isDebugFlagSet(): boolean {
Expand Down
26 changes: 26 additions & 0 deletions packages/nuqs/src/lib/devtools/buffer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { NuqsLogEvent } from './events'

export const MAX_EVENTS = 500

// Anchored on globalThis (like the event client) so the sink and the panel share
// one buffer even if the module is instantiated more than once. Survives the
// panel unmounting on a devtools tab switch; the panel reads it on (re)mount to
// backfill, then appends live via the bus.
const store = ((
globalThis as { __nuqs_devtools_buffer__?: { events: NuqsLogEvent[] } }
).__nuqs_devtools_buffer__ ??= { events: [] })

export function pushEvent(event: NuqsLogEvent): void {
store.events.push(event)
if (store.events.length > MAX_EVENTS) {
store.events.splice(0, store.events.length - MAX_EVENTS)
}
}

export function getEvents(): readonly NuqsLogEvent[] {
return store.events
}

export function clearEvents(): void {
store.events = []
}
22 changes: 22 additions & 0 deletions packages/nuqs/src/lib/devtools/category.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { DebugCode } from '../debug-messages'

export type LogCategory =
| 'state'
| 'throttle'
| 'debounce'
| 'queue'
| 'adapter'
| 'parse'

/**
* Map a debug code to a coarse category, mirroring the comment groups in the
* catalog. Drives at-a-glance grouping in the panel (and, later, filtering).
*/
export function categoryForCode(code: DebugCode): LogCategory {
if (code <= 6) return 'state' // useQueryStates
if (code <= 12) return 'throttle' // global throttle queue (gtq)
if (code <= 18) return 'debounce' // debounce queue (dq / dqc)
if (code === 19) return 'queue' // aborting queues
if (code <= 23) return 'adapter' // adapters & key isolation
return 'parse' // safe-parse (24, 25)
}
60 changes: 60 additions & 0 deletions packages/nuqs/src/lib/devtools/events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { EventClient } from '@tanstack/devtools-event-client'
import type { DebugArgs, DebugCode } from '../debug-messages'
import type { LogCategory } from './category'

type LogLevel = 'log' | 'warn'

/**
* A single normalized debug event sent over the TanStack bus.
*
* Discriminated by `code` so the panel narrows to the exact per-code argument
* tuple (`DebugArgs<Code>`), recovering the same type precision the catalog
* gives the `debug()` call sites. `args` carry the sink-normalized values
* (cloned for immutability, functions replaced with a marker), and `message`
* is the ready-to-render formatted line.
*/
export type NuqsLogEvent = {
[Code in DebugCode]: {
id: number
ts: number
level: LogLevel
code: Code
category: LogCategory
message: string
args: DebugArgs<Code>
}
}[DebugCode]

type NuqsDevtoolsEventMap = {
log: NuqsLogEvent
}

class NuqsEventClient extends EventClient<NuqsDevtoolsEventMap> {
constructor() {
super({ pluginId: 'nuqs' })
}
}

// Anchor singletons on globalThis: bundlers (e.g. Next with a `'use client'`
// entry) can instantiate this module more than once, which would split the sink
// (emits) from the panel (subscribes) across separate clients and buffers. A
// global store keeps them on a single instance.
type DevtoolsGlobal = {
client?: NuqsEventClient
seq: number
}
const store: DevtoolsGlobal = ((
globalThis as { __nuqs_devtools__?: DevtoolsGlobal }
).__nuqs_devtools__ ??= { seq: 0 })

/**
* Singleton bus shared by the sink (emits) and the panel (subscribes).
* The `nuqs` pluginId namespaces events as `nuqs:log` on the TanStack bus.
*/
export const eventClient: NuqsEventClient = (store.client ??=
new NuqsEventClient())

/** Monotonic id for stable list keys and buffer/bus dedup. */
export function nextEventId(): number {
return store.seq++
}
Loading
Loading