diff --git a/knip.json b/knip.json index 16ebe7f72..f573bbbcd 100644 --- a/knip.json +++ b/knip.json @@ -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", diff --git a/packages/docs/package.json b/packages/docs/package.json index d6a5fffb2..921397598 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -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", diff --git a/packages/docs/src/app/layout.tsx b/packages/docs/src/app/layout.tsx index 3d7a025bd..163e0e3d3 100644 --- a/packages/docs/src/app/layout.tsx +++ b/packages/docs/src/app/layout.tsx @@ -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' @@ -76,6 +77,7 @@ export default function Layout({ children }: { children: ReactNode }) { )} + ) diff --git a/packages/docs/src/components/nuqs-devtools-impl.tsx b/packages/docs/src/components/nuqs-devtools-impl.tsx new file mode 100644 index 000000000..15af5b7a9 --- /dev/null +++ b/packages/docs/src/components/nuqs-devtools-impl.tsx @@ -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 ( + }]} /> + ) +} diff --git a/packages/docs/src/components/nuqs-devtools.tsx b/packages/docs/src/components/nuqs-devtools.tsx new file mode 100644 index 000000000..b022855ea --- /dev/null +++ b/packages/docs/src/components/nuqs-devtools.tsx @@ -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 }) diff --git a/packages/nuqs/package.json b/packages/nuqs/package.json index 784a5919e..33a78a3fd 100644 --- a/packages/nuqs/package.json +++ b/packages/nuqs/package.json @@ -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", @@ -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", @@ -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", @@ -167,6 +174,9 @@ "@remix-run/react": { "optional": true }, + "@tanstack/devtools-event-client": { + "optional": true + }, "@tanstack/react-router": { "optional": true }, @@ -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", diff --git a/packages/nuqs/src/api.test.ts b/packages/nuqs/src/api.test.ts index d6c51028f..f3180d639 100644 --- a/packages/nuqs/src/api.test.ts +++ b/packages/nuqs/src/api.test.ts @@ -78,6 +78,9 @@ const exports = ` "withNuqsTestingAdapter": "function", }, "./debug": {}, + "./devtools": { + "NuqsDevtools": "function", + }, "./server": { "createLoader": "function", "createMultiParser": "function", diff --git a/packages/nuqs/src/debug.ts b/packages/nuqs/src/debug.ts index d089c3074..59f4e2125 100644 --- a/packages/nuqs/src/debug.ts +++ b/packages/nuqs/src/debug.ts @@ -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 @@ -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) diff --git a/packages/nuqs/src/devtools.ts b/packages/nuqs/src/devtools.ts new file mode 100644 index 000000000..81a9ee4e4 --- /dev/null +++ b/packages/nuqs/src/devtools.ts @@ -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' +// +// }]} /> +// ``` +export { NuqsDevtools } from './lib/devtools/panel' +export type { NuqsLogEvent } from './lib/devtools/events' + +if ( + typeof window !== 'undefined' && + (process.env.NODE_ENV !== 'production' || isDebugFlagSet()) +) { + installNuqsDevtoolsSink() +} diff --git a/packages/nuqs/src/lib/debug.test.ts b/packages/nuqs/src/lib/debug.test.ts index e378a303e..fd0c4ab9a 100644 --- a/packages/nuqs/src/lib/debug.test.ts +++ b/packages/nuqs/src/lib/debug.test.ts @@ -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') diff --git a/packages/nuqs/src/lib/debug.ts b/packages/nuqs/src/lib/debug.ts index 6e563683d..bb836cbc8 100644 --- a/packages/nuqs/src/lib/debug.ts +++ b/packages/nuqs/src/lib/debug.ts @@ -9,27 +9,44 @@ export type DebugSink = ( isWarn?: boolean ) => void -let sink: DebugSink | null = null +const sinks = new Set() /** - * 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: Code, ...args: DebugArgs ): 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: Code, ...args: DebugArgs ): void { - sink?.(code, args, true) + if (sinks.size === 0) { + return + } + for (const sink of sinks) { + sink(code, args, true) + } } export function isDebugFlagSet(): boolean { diff --git a/packages/nuqs/src/lib/devtools/buffer.ts b/packages/nuqs/src/lib/devtools/buffer.ts new file mode 100644 index 000000000..602cf47d6 --- /dev/null +++ b/packages/nuqs/src/lib/devtools/buffer.ts @@ -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 = [] +} diff --git a/packages/nuqs/src/lib/devtools/category.ts b/packages/nuqs/src/lib/devtools/category.ts new file mode 100644 index 000000000..79f029396 --- /dev/null +++ b/packages/nuqs/src/lib/devtools/category.ts @@ -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) +} diff --git a/packages/nuqs/src/lib/devtools/events.ts b/packages/nuqs/src/lib/devtools/events.ts new file mode 100644 index 000000000..76ec13278 --- /dev/null +++ b/packages/nuqs/src/lib/devtools/events.ts @@ -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`), 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 + } +}[DebugCode] + +type NuqsDevtoolsEventMap = { + log: NuqsLogEvent +} + +class NuqsEventClient extends EventClient { + 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++ +} diff --git a/packages/nuqs/src/lib/devtools/normalize.test.ts b/packages/nuqs/src/lib/devtools/normalize.test.ts new file mode 100644 index 000000000..1f8dd81b4 --- /dev/null +++ b/packages/nuqs/src/lib/devtools/normalize.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest' +import { normalize } from './normalize' + +describe('devtools/normalize', () => { + it('passes primitives through', () => { + expect(normalize(1)).toBe(1) + expect(normalize('x')).toBe('x') + expect(normalize(true)).toBe(true) + expect(normalize(null)).toBe(null) + expect(normalize(undefined)).toBe(undefined) + }) + + it('replaces functions with a presence marker', () => { + expect(normalize(() => {})).toBe('[Function]') + const out = normalize({ startTransition: () => {}, history: 'push' }) + expect(out).toEqual({ startTransition: '[Function]', history: 'push' }) + }) + + it('clones URLSearchParams, preserving type and entries', () => { + const sp = new URLSearchParams('a=1&b=2') + const out = normalize(sp) + expect(out).toBeInstanceOf(URLSearchParams) + expect(out).not.toBe(sp) + expect((out as URLSearchParams).toString()).toBe('a=1&b=2') + }) + + it('clones URL, preserving href', () => { + const url = new URL('https://example.com/p?q=1') + const out = normalize(url) + expect(out).toBeInstanceOf(URL) + expect(out).not.toBe(url) + expect((out as URL).href).toBe(url.href) + }) + + it('keeps the Error reference so instanceof holds', () => { + const err = new Error('boom') + expect(normalize(err)).toBe(err) + }) + + it('clones Date, Map and Set', () => { + const date = new Date(0) + expect(normalize(date)).toBeInstanceOf(Date) + expect(normalize(date)).not.toBe(date) + expect(normalize(new Map([['k', 1]]))).toEqual(new Map([['k', 1]])) + expect(normalize(new Set([1, 2]))).toEqual(new Set([1, 2])) + }) + + it('snapshots against later in-place mutation', () => { + const sp = new URLSearchParams('count=1') + const out = normalize(sp) as URLSearchParams + sp.set('count', '2') + expect(out.get('count')).toBe('1') + }) + + it('guards reference cycles', () => { + const obj: Record = { a: 1 } + obj.self = obj + const out = normalize(obj) as Record + expect(out.a).toBe(1) + expect(out.self).toBe(out) + }) + + it('reduces class instances to plain data, dropping methods', () => { + class Point { + x = 1 + y = 2 + distance() { + return Math.hypot(this.x, this.y) + } + } + expect(normalize(new Point())).toEqual({ x: 1, y: 2 }) + }) +}) diff --git a/packages/nuqs/src/lib/devtools/normalize.ts b/packages/nuqs/src/lib/devtools/normalize.ts new file mode 100644 index 000000000..91b8a181e --- /dev/null +++ b/packages/nuqs/src/lib/devtools/normalize.ts @@ -0,0 +1,69 @@ +const FUNCTION_MARKER = '[Function]' + +/** + * Snapshot a debug argument into an immutable, inspectable value. + * + * Runs in the sink right before emit (never on the hot path when no sink is + * attached). nuqs mutates queues and `URLSearchParams` in place, so cloning is + * what keeps the log history from rewriting itself. Built-ins we care about + * (`URL`, `URLSearchParams`, `Error`, `Date`, `Map`, `Set`) keep their type so + * the panel can render them with dedicated inspectors; functions collapse to a + * presence marker (we log *that* an option is set, not its body); cycles are + * guarded, and anything that resists cloning falls back to a string. + */ +export function normalize(value: unknown): unknown { + try { + return clone(value, new WeakMap()) + } catch { + try { + return String(value) + } catch { + return '[Unserializable]' + } + } +} + +function clone(value: unknown, seen: WeakMap): unknown { + if (typeof value === 'function') { + return FUNCTION_MARKER + } + if (value === null || typeof value !== 'object') { + return value + } + // Type-preserving clones for the inspectable built-ins we log. + if (value instanceof URLSearchParams) return new URLSearchParams(value) + if (value instanceof URL) return new URL(value.href) + if (value instanceof Date) return new Date(value.getTime()) + // Errors are immutable in practice; keep the reference so `instanceof` holds. + if (value instanceof Error) return value + const cached = seen.get(value) + if (cached !== undefined) { + return cached + } + if (Array.isArray(value)) { + const out: unknown[] = [] + seen.set(value, out) + for (const item of value) out.push(clone(item, seen)) + return out + } + if (value instanceof Map) { + const out = new Map() + seen.set(value, out) + for (const [k, v] of value) out.set(k, clone(v, seen)) + return out + } + if (value instanceof Set) { + const out = new Set() + seen.set(value, out) + for (const v of value) out.add(clone(v, seen)) + return out + } + // Plain object or unknown class instance: copy own enumerable props, dropping + // the prototype (methods are non-enumerable, so they fall away here). + const out: Record = {} + seen.set(value, out) + for (const [k, v] of Object.entries(value as Record)) { + out[k] = clone(v, seen) + } + return out +} diff --git a/packages/nuqs/src/lib/devtools/panel.browser.test.tsx b/packages/nuqs/src/lib/devtools/panel.browser.test.tsx new file mode 100644 index 000000000..1e0e0e6df --- /dev/null +++ b/packages/nuqs/src/lib/devtools/panel.browser.test.tsx @@ -0,0 +1,64 @@ +import React from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { render } from 'vitest-browser-react' +import { page, userEvent } from 'vitest/browser' +import { debug, warn } from '../debug' +import { clearEvents } from './buffer' +import { eventClient } from './events' +import { NuqsDevtools } from './panel' +import { installNuqsDevtoolsSink } from './sink' + +// The panel backfills from the buffer on mount, so events fired before render +// show up without a live devtools shell. Mock the bus emit to keep the +// EventClient from starting its connect/retry loop. +function seedSink() { + vi.spyOn(eventClient, 'emit').mockImplementation(() => {}) + return installNuqsDevtoolsSink() +} + +describe('NuqsDevtools panel', () => { + let removeSink: (() => void) | undefined + afterEach(() => { + removeSink?.() + removeSink = undefined + clearEvents() + vi.restoreAllMocks() + }) + + it('backfills and renders buffered events on mount', async () => { + removeSink = seedSink() + debug(20, 'react', new URL('https://example.com/?a=1')) + warn(24, 'oops', new Error('bad value')) + render() + await expect.element(page.getByText(/Updating url/)).toBeVisible() + await expect.element(page.getByText(/Error while parsing/)).toBeVisible() + }) + + it('filters by free-text search over the message', async () => { + removeSink = seedSink() + debug(20, 'react', new URL('https://example.com/?a=1')) + debug(8) // Skipping flush due to throttleMs=Infinity + render() + await userEvent.fill(page.getByPlaceholder(/Filter/), 'Skipping') + await expect.element(page.getByText(/Skipping flush/)).toBeVisible() + await expect.element(page.getByText(/Updating url/)).not.toBeInTheDocument() + }) + + it('clears the log', async () => { + removeSink = seedSink() + debug(8) + render() + await expect.element(page.getByText(/Skipping flush/)).toBeVisible() + await userEvent.click(page.getByRole('button', { name: 'Clear' })) + await expect.element(page.getByText(/No events/)).toBeVisible() + }) + + it('renders a URLSearchParams inspector when a row is expanded', async () => { + removeSink = seedSink() + debug(22, 'myKey', new URLSearchParams('foo=bar')) + render() + await userEvent.click(page.getByText(/no change, returning previous/)) + await expect.element(page.getByText('foo', { exact: true })).toBeVisible() + await expect.element(page.getByText('bar', { exact: true })).toBeVisible() + }) +}) diff --git a/packages/nuqs/src/lib/devtools/panel.ts b/packages/nuqs/src/lib/devtools/panel.ts new file mode 100644 index 000000000..0eabc17d5 --- /dev/null +++ b/packages/nuqs/src/lib/devtools/panel.ts @@ -0,0 +1,322 @@ +import { + createElement as h, + useEffect, + useMemo, + useRef, + useState, + type CSSProperties, + type ReactElement +} from 'react' +import { clearEvents, getEvents, MAX_EVENTS } from './buffer' +import type { LogCategory } from './category' +import { eventClient, type NuqsLogEvent } from './events' + +const CATEGORY_COLORS: Record = { + state: '#7dd3fc', + throttle: '#fcd34d', + debounce: '#fdba74', + queue: '#f9a8d4', + adapter: '#86efac', + parse: '#fca5a5' +} + +/** + * The nuqs devtools panel: a live, filterable view of the debug log stream. + * Renders inside a TanStack Devtools tab via + * ` }]} />`. + */ +export function NuqsDevtools(): ReactElement { + const [events, setEvents] = useState([]) + const [search, setSearch] = useState('') + const [follow, setFollow] = useState(true) + const lastSeenId = useRef(-1) + const pending = useRef([]) + const rafId = useRef(null) + const listRef = useRef(null) + + useEffect(() => { + // nuqs calls `debug()` during render/commit and the bus dispatches + // synchronously, so appending directly here would set state while another + // component renders. Batch into a ref and flush on an animation frame, which + // decouples the update and coalesces bursts. + const flush = () => { + rafId.current = null + const batch = pending.current + if (batch.length === 0) return + pending.current = [] + setEvents(prev => { + const merged = prev.concat(batch) + return merged.length > MAX_EVENTS + ? merged.slice(merged.length - MAX_EVENTS) + : merged + }) + } + const enqueue = (event: NuqsLogEvent) => { + // Ids are monotonic, so a watermark dedups the backfill (incl. StrictMode's + // double-invoke) in O(1) without an unbounded Set. + if (event.id <= lastSeenId.current) return + lastSeenId.current = event.id + pending.current.push(event) + rafId.current ??= requestAnimationFrame(flush) + } + // Subscribe first, then backfill from the buffer: a CustomEvent dispatch + // can't interleave between these synchronous statements. + const unsubscribe = eventClient.on('log', e => enqueue(e.payload)) + for (const event of getEvents()) enqueue(event) + // Schedule the backfill flush explicitly: under StrictMode the first setup's + // batch survives cleanup, and the second setup's re-backfill is fully + // watermark-deduped, so no `enqueue` would reschedule it on its own. + if (pending.current.length > 0) { + rafId.current ??= requestAnimationFrame(flush) + } + return () => { + unsubscribe() + if (rafId.current != null) { + // Null the handle too: leaving a stale id would make the `??=` guard in + // `enqueue` skip rescheduling after a remount, so `flush` never runs. + cancelAnimationFrame(rafId.current) + rafId.current = null + } + } + }, []) + + useEffect(() => { + if (follow && listRef.current) { + listRef.current.scrollTop = listRef.current.scrollHeight + } + }, [events, follow]) + + const filtered = useMemo(() => { + const needle = search.trim().toLowerCase() + if (!needle) return events + return events.filter(event => event.message.toLowerCase().includes(needle)) + }, [events, search]) + + const onClear = () => { + clearEvents() + pending.current = [] + setEvents([]) + } + + const onScroll = () => { + const el = listRef.current + if (!el) return + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 24 + if (atBottom !== follow) setFollow(atBottom) + } + + return h( + 'div', + { style: styles.root }, + h( + 'div', + { style: styles.toolbar }, + h('input', { + style: styles.search, + placeholder: 'Filter (key, message…)', + value: search, + onChange: e => setSearch(e.currentTarget.value) + }), + h( + 'label', + { style: styles.follow }, + h('input', { + type: 'checkbox', + checked: follow, + onChange: e => setFollow(e.currentTarget.checked) + }), + 'Follow' + ), + h('button', { style: styles.button, onClick: onClear }, 'Clear'), + h('span', { style: styles.count }, `${filtered.length}/${events.length}`) + ), + h( + 'div', + { ref: listRef, style: styles.list, onScroll }, + filtered.length === 0 + ? h('div', { style: styles.empty }, 'No events. Interact with the app.') + : filtered.map(event => h(LogRow, { key: event.id, event })) + ) + ) +} + +function LogRow({ event }: { event: NuqsLogEvent }): ReactElement { + return h( + 'details', + { style: styles.row }, + h( + 'summary', + { style: styles.summary }, + h('span', { style: styles.time }, formatTime(event.ts)), + h( + 'span', + { + style: { + ...styles.badge, + color: CATEGORY_COLORS[event.category] + } + }, + event.category + ), + event.level === 'warn' ? h('span', { style: styles.warn }, 'warn') : null, + h('span', { style: styles.message }, event.message) + ), + h( + 'div', + { style: styles.argsBox }, + event.args.length === 0 + ? h('span', { style: styles.muted }, '(no args)') + : event.args.map((arg, i) => h(ArgView, { key: i, arg })) + ) + ) +} + +function ArgView({ arg }: { arg: unknown }): ReactElement { + if (arg instanceof URLSearchParams) { + return h( + 'table', + { style: styles.table }, + h( + 'tbody', + null, + Array.from(arg.entries()).map(([key, value], i) => + h( + 'tr', + { key: i }, + h('td', { style: styles.tdKey }, key), + h('td', { style: styles.tdValue }, value) + ) + ) + ) + ) + } + if (arg instanceof URL) { + return h( + 'div', + { style: styles.urlBox }, + h('div', null, h('b', null, 'URL '), arg.href), + h(ArgView, { arg: arg.searchParams }) + ) + } + if (arg instanceof Error) { + return h( + 'pre', + { style: { ...styles.pre, color: '#fca5a5' } }, + `${arg.name}: ${arg.message}\n${arg.stack ?? ''}` + ) + } + return h('pre', { style: styles.pre }, safeStringify(arg)) +} + +function safeStringify(value: unknown): string { + if (typeof value === 'string') return value + try { + return JSON.stringify(value, null, 2) ?? String(value) + } catch { + return String(value) + } +} + +function formatTime(ts: number): string { + const d = new Date(ts) + const pad = (n: number, l = 2) => String(n).padStart(l, '0') + return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}` +} + +const mono = + 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace' + +const styles: Record = { + // `margin: 0` on every element: the host page's global CSS (typography resets, + // owl selectors) would otherwise inflate the flex toolbar's line height and + // collapse the list. + // Grid (not flex column): the host container triggers a flex-basis anomaly + // that lets the auto-height toolbar balloon and collapse the list. A grid with + // an `auto` toolbar row and a `minmax(0, 1fr)` scrollable list row is immune. + root: { + display: 'grid', + gridTemplateRows: 'auto minmax(0, 1fr)', + height: '100%', + minHeight: 200, + margin: 0, + boxSizing: 'border-box', + font: `12px/1.5 ${mono}`, + color: '#e5e7eb', + background: '#0b0f19' + }, + toolbar: { + display: 'flex', + flexShrink: 0, + gap: 8, + alignItems: 'center', + padding: 8, + margin: 0, + borderBottom: '1px solid #1f2937' + }, + search: { + flex: 1, + margin: 0, + padding: '4px 8px', + background: '#111827', + border: '1px solid #1f2937', + borderRadius: 4, + color: '#e5e7eb', + font: `12px ${mono}` + }, + follow: { + display: 'flex', + gap: 4, + margin: 0, + alignItems: 'center', + userSelect: 'none' + }, + button: { + padding: '4px 10px', + margin: 0, + background: '#1f2937', + border: '1px solid #374151', + borderRadius: 4, + color: '#e5e7eb', + cursor: 'pointer' + }, + count: { color: '#6b7280', margin: 0, minWidth: 56, textAlign: 'right' }, + list: { minHeight: 0, overflow: 'auto', padding: 4, margin: 0 }, + empty: { padding: 16, color: '#6b7280', textAlign: 'center' }, + row: { borderBottom: '1px solid #111827' }, + summary: { + display: 'flex', + gap: 8, + alignItems: 'baseline', + padding: '3px 4px', + cursor: 'pointer', + whiteSpace: 'nowrap', + overflow: 'hidden' + }, + time: { color: '#4b5563', flexShrink: 0 }, + badge: { flexShrink: 0, fontWeight: 600 }, + warn: { color: '#fbbf24', flexShrink: 0 }, + message: { color: '#cbd5e1', overflow: 'hidden', textOverflow: 'ellipsis' }, + argsBox: { padding: '4px 8px 8px 24px', display: 'grid', gap: 6 }, + muted: { color: '#6b7280' }, + pre: { + margin: 0, + padding: 8, + background: '#111827', + borderRadius: 4, + overflow: 'auto', + whiteSpace: 'pre-wrap', + wordBreak: 'break-word' + }, + table: { borderCollapse: 'collapse', background: '#111827', borderRadius: 4 }, + tdKey: { + padding: '2px 8px', + color: '#7dd3fc', + borderBottom: '1px solid #1f2937' + }, + tdValue: { + padding: '2px 8px', + color: '#e5e7eb', + borderBottom: '1px solid #1f2937' + }, + urlBox: { display: 'grid', gap: 4 } +} diff --git a/packages/nuqs/src/lib/devtools/sink.test.ts b/packages/nuqs/src/lib/devtools/sink.test.ts new file mode 100644 index 000000000..75ba5ea0a --- /dev/null +++ b/packages/nuqs/src/lib/devtools/sink.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { debug, warn } from '../debug' +import { clearEvents, getEvents } from './buffer' +import { eventClient } from './events' +import { installNuqsDevtoolsSink } from './sink' + +// Mock the bus emit so installing the sink doesn't kick off the EventClient's +// connect/retry loop (no devtools shell is present in unit tests). +function withSink() { + const emit = vi.spyOn(eventClient, 'emit').mockImplementation(() => {}) + const remove = installNuqsDevtoolsSink() + return { emit, remove } +} + +describe('devtools/sink', () => { + afterEach(() => { + clearEvents() + vi.restoreAllMocks() + }) + + it('records a normalized, immutable event and emits it on the bus', () => { + const { emit, remove } = withSink() + const url = new URL('https://example.com/?a=1') + debug(20, 'react', url) + + const events = getEvents() + expect(events).toHaveLength(1) + const event = events[0]! + expect(event.code).toBe(20) + expect(event.category).toBe('adapter') + expect(event.level).toBe('log') + expect(event.message).toContain('Updating url') + expect(event.args[0]).toBe('react') + expect(event.args[1]).toBeInstanceOf(URL) + expect(event.args[1]).not.toBe(url) // a cloned snapshot, not the live ref + expect(emit).toHaveBeenCalledWith('log', event) + remove() + }) + + it('replaces functions in logged options with a presence marker', () => { + const { remove } = withSink() + debug(7, 'q', 'value', { + startTransition: () => {}, + history: 'push' + }) + expect(getEvents().at(-1)!.args[2]).toEqual({ + startTransition: '[Function]', + history: 'push' + }) + remove() + }) + + it('flags warnings with the warn level', () => { + const { remove } = withSink() + warn(24, 'value', new Error('boom')) + expect(getEvents().at(-1)!.level).toBe('warn') + remove() + }) + + it('never throws when an arg resists JSON formatting (cycle / BigInt)', () => { + const { remove } = withSink() + const cyclic: Record = {} + cyclic.self = cyclic + expect(() => debug(6, 'id', 'key', cyclic)).not.toThrow() + expect(() => debug(6, 'id', 'key', { big: 1n })).not.toThrow() + // The event is kept, falling back to the raw template rather than dropped. + expect(getEvents().at(-1)!.message).toContain('setState') + remove() + }) +}) diff --git a/packages/nuqs/src/lib/devtools/sink.ts b/packages/nuqs/src/lib/devtools/sink.ts new file mode 100644 index 000000000..9e2ac6c83 --- /dev/null +++ b/packages/nuqs/src/lib/devtools/sink.ts @@ -0,0 +1,41 @@ +import { addDebugSink } from '../debug' +import { debugMessages, sprintf, type DebugCode } from '../debug-messages' +import { pushEvent } from './buffer' +import { categoryForCode } from './category' +import { eventClient, nextEventId, type NuqsLogEvent } from './events' +import { normalize } from './normalize' + +function formatMessage(code: DebugCode, args: unknown[]): string { + // sprintf's %O runs JSON.stringify, which throws on cycles or BigInt. Logging + // must never throw into the app's update path, so fall back to the raw + // template (the structured args stay available in the panel's inspector). + try { + return sprintf(debugMessages[code], ...args) + } catch { + return debugMessages[code] + } +} + +/** + * Register the devtools sink: on each debug call, snapshot the args (immutable + + * serializable), build the log event, store it for backfill, and emit it on the + * bus. All normalization happens here, so a debug call costs nothing when no + * sink is attached. Returns a remover (unused today, the sink lives for the + * page lifetime, but kept symmetric with `addDebugSink`). + */ +export function installNuqsDevtoolsSink(): () => void { + return addDebugSink((code, args, isWarn) => { + const normalizedArgs = args.map(normalize) + const event = { + id: nextEventId(), + ts: Date.now(), + level: isWarn ? 'warn' : 'log', + code, + category: categoryForCode(code), + message: formatMessage(code, normalizedArgs), + args: normalizedArgs + } as NuqsLogEvent + pushEvent(event) + eventClient.emit('log', event) + }) +} diff --git a/packages/nuqs/tsdown.config.ts b/packages/nuqs/tsdown.config.ts index 71aa6f5f2..8024b01d7 100644 --- a/packages/nuqs/tsdown.config.ts +++ b/packages/nuqs/tsdown.config.ts @@ -13,7 +13,8 @@ const commonConfig = { '@remix-run/react', 'react-router-dom', 'react-router', - '@tanstack/react-router' + '@tanstack/react-router', + '@tanstack/devtools-event-client' ], outExtensions() { return { @@ -22,11 +23,17 @@ const commonConfig = { } }, treeshake: { - // `src/debug.ts` has a top-level side effect: it auto-enables logging when - // the `DEBUG`/`localStorage.debug` flag is set. - // Returning `undefined` defers every other module to the package.json `sideEffects` allowlist. + // `src/debug.ts` and `src/devtools.ts` have top-level side effects: they + // auto-install their sink when the `DEBUG`/`localStorage.debug` flag is set + // (and, for devtools, in development). + // Returning `undefined` defers every other module to the package.json `sideEffects` allowlist. moduleSideEffects(id) { - return id.replace(/\\/g, '/').endsWith('/src/debug.ts') || undefined + const path = id.replace(/\\/g, '/') + return ( + path.endsWith('/src/debug.ts') || + path.endsWith('/src/devtools.ts') || + undefined + ) } }, tsconfig: 'tsconfig.build.json' @@ -36,6 +43,7 @@ const entrypoints = { client: { index: 'src/index.ts', debug: 'src/debug.ts', + devtools: 'src/devtools.ts', 'adapters/react': 'src/adapters/react.ts', 'adapters/next': 'src/adapters/next.ts', 'adapters/next/app': 'src/adapters/next/app.ts', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69e33e95f..c60ef10d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -175,6 +175,12 @@ importers: '@tailwindcss/postcss': specifier: ^4.1.18 version: 4.1.18 + '@tanstack/devtools-event-client': + specifier: 0.4.4 + version: 0.4.4 + '@tanstack/react-devtools': + specifier: ^0.10.7 + version: 0.10.7(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(csstype@3.2.3)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.13) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -832,6 +838,9 @@ importers: '@size-limit/preset-small-lib': specifier: ^12.0.0 version: 12.0.0(size-limit@12.0.0(jiti@2.7.0)) + '@tanstack/devtools-event-client': + specifier: 0.4.4 + version: 0.4.4 '@types/node': specifier: ^24.10.10 version: 24.10.10 @@ -3809,6 +3818,36 @@ packages: peerDependencies: size-limit: 12.0.0 + '@solid-primitives/event-listener@2.4.5': + resolution: {integrity: sha512-nwRV558mIabl4yVAhZKY8cb6G+O1F0M6Z75ttTu5hk+SxdOnKSGj+eetDIu7Oax1P138ZdUU01qnBPR8rnxaEA==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/keyboard@1.3.5': + resolution: {integrity: sha512-sav+l+PL+74z3yaftVs7qd8c2SXkqzuxPOVibUe5wYMt+U5Hxp3V3XCPgBPN2I6cANjvoFtz0NiU8uHVLdi9FQ==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/resize-observer@2.1.5': + resolution: {integrity: sha512-AiyTknKcNBaKHbcSMuxtSNM8FjIuiSuFyFghdD0TcCMU9hKi9EmsC5pjfjDwxE+5EueB1a+T/34PLRI5vbBbKw==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/rootless@1.5.3': + resolution: {integrity: sha512-N8cIDAHbWcLahNRLr0knAAQvXyEdEMoAZvIMZKmhNb1mlx9e2UOv9BRD5YNwQUJwbNoYVhhLwFOEOcVXFx0HqA==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/static-store@0.1.3': + resolution: {integrity: sha512-uxez7SXnr5GiRnzqO2IEDjOJRIXaG+0LZLBizmUA1FwSi+hrpuMzVBwyk70m4prcl8X6FDDXUl9O8hSq8wHbBQ==} + peerDependencies: + solid-js: ^1.6.12 + + '@solid-primitives/utils@6.4.0': + resolution: {integrity: sha512-AeGTBg8Wtkh/0s+evyLtP8piQoS4wyqqQaAFs2HJcFMMjYAtUgo+ZPduRXLjPlqKVc2ejeR544oeqpbn8Egn8A==} + peerDependencies: + solid-js: ^1.6.12 + '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} @@ -3935,6 +3974,32 @@ packages: '@tailwindcss/postcss@4.1.18': resolution: {integrity: sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==} + '@tanstack/devtools-client@0.0.7': + resolution: {integrity: sha512-bAqBnXQlg/1PqmIC3XhqzG8jV3YmUQ41fD9VlOVzSilFBD4Kp6WJFJa+7N6TvlYXqMAy8xoeF9gg0d2Lt74OEQ==} + engines: {node: '>=18'} + + '@tanstack/devtools-event-bus@0.4.2': + resolution: {integrity: sha512-2LHzhwBFlKHCcklsQrGe8TeyjHd4XAF8nuCO6wHmva5fePUkJUULbu6CsCNAlGlCi0KkEsMXZSvRdR4HgMq4yA==} + engines: {node: '>=18'} + + '@tanstack/devtools-event-client@0.4.4': + resolution: {integrity: sha512-6T5Yop/793YI+H+5J8Hsyj4kCih9sl4t3ElLgKioW5hk3ocn+ZdSJ94tT7vL7uabxSugWYBZlOTMPzEw2puvQw==} + engines: {node: '>=18'} + hasBin: true + + '@tanstack/devtools-ui@0.5.3': + resolution: {integrity: sha512-iJjwWtdXhUGpeHyyW9+3NhXhmlVFhh3v3UBNKCouykG9UFXEtneVVNXgSRpd70DeYJFmvKOY19LafKRI5/cM7A==} + engines: {node: '>=18'} + peerDependencies: + solid-js: '>=1.9.7' + + '@tanstack/devtools@0.12.4': + resolution: {integrity: sha512-fYZ0KTEpKq7JyjULDe4kGQBN77aw5jtULs1MaVraWvwtcoIwe4UOKV48UJQ9/mEQMFTytbgKxYk2QaSDgI/Znw==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + solid-js: '>=1.9.7' + '@tanstack/history@1.154.14': resolution: {integrity: sha512-xyIfof8eHBuub1CkBnbKNKQXeRZC4dClhmzePHVOEel4G7lk/dW+TQ16da7CFdeNLv6u6Owf5VoBQxoo6DFTSA==} engines: {node: '>=12'} @@ -3945,6 +4010,15 @@ packages: '@tanstack/query-devtools@5.93.0': resolution: {integrity: sha512-+kpsx1NQnOFTZsw6HAFCW3HkKg0+2cepGtAWXjiiSOJJ1CtQpt72EE2nyZb+AjAbLRPoeRmPJ8MtQd8r8gsPdg==} + '@tanstack/react-devtools@0.10.7': + resolution: {integrity: sha512-AYHQH06uuK07Asqq8eASgJjpILlaFBpjnTesxx1JVHGoBl4ijwbyIlKnj3Z8+M8sEOPn5mvtV5o6mielPXKHWg==} + engines: {node: '>=18'} + peerDependencies: + '@types/react': '>=16.8' + '@types/react-dom': '>=16.8' + react: '>=16.8' + react-dom: '>=16.8' + '@tanstack/react-query-devtools@5.91.3': resolution: {integrity: sha512-nlahjMtd/J1h7IzOOfqeyDh5LNfG0eULwlltPEonYy0QL+nqrBB+nyzJfULV+moL7sZyxc2sHdNJki+vLA9BSA==} peerDependencies: @@ -7692,6 +7766,9 @@ packages: resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} engines: {node: '>= 18'} + solid-js@1.9.13: + resolution: {integrity: sha512-6hJeJMOcEX8ktqjpDoJZEmld3ijvcvWBDtiXBm7f4332SiFN66QeAQI1REQshvyUoISsSeJ4PHDauKYbwao9JQ==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -11221,6 +11298,40 @@ snapshots: '@size-limit/file': 12.0.0(size-limit@12.0.0(jiti@2.7.0)) size-limit: 12.0.0(jiti@2.7.0) + '@solid-primitives/event-listener@2.4.5(solid-js@1.9.13)': + dependencies: + '@solid-primitives/utils': 6.4.0(solid-js@1.9.13) + solid-js: 1.9.13 + + '@solid-primitives/keyboard@1.3.5(solid-js@1.9.13)': + dependencies: + '@solid-primitives/event-listener': 2.4.5(solid-js@1.9.13) + '@solid-primitives/rootless': 1.5.3(solid-js@1.9.13) + '@solid-primitives/utils': 6.4.0(solid-js@1.9.13) + solid-js: 1.9.13 + + '@solid-primitives/resize-observer@2.1.5(solid-js@1.9.13)': + dependencies: + '@solid-primitives/event-listener': 2.4.5(solid-js@1.9.13) + '@solid-primitives/rootless': 1.5.3(solid-js@1.9.13) + '@solid-primitives/static-store': 0.1.3(solid-js@1.9.13) + '@solid-primitives/utils': 6.4.0(solid-js@1.9.13) + solid-js: 1.9.13 + + '@solid-primitives/rootless@1.5.3(solid-js@1.9.13)': + dependencies: + '@solid-primitives/utils': 6.4.0(solid-js@1.9.13) + solid-js: 1.9.13 + + '@solid-primitives/static-store@0.1.3(solid-js@1.9.13)': + dependencies: + '@solid-primitives/utils': 6.4.0(solid-js@1.9.13) + solid-js: 1.9.13 + + '@solid-primitives/utils@6.4.0(solid-js@1.9.13)': + dependencies: + solid-js: 1.9.13 + '@standard-schema/spec@1.0.0': {} '@standard-schema/spec@1.1.0': {} @@ -11311,12 +11422,63 @@ snapshots: postcss: 8.5.15 tailwindcss: 4.1.18 + '@tanstack/devtools-client@0.0.7': + dependencies: + '@tanstack/devtools-event-client': 0.4.4 + + '@tanstack/devtools-event-bus@0.4.2': + dependencies: + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@tanstack/devtools-event-client@0.4.4': {} + + '@tanstack/devtools-ui@0.5.3(csstype@3.2.3)(solid-js@1.9.13)': + dependencies: + clsx: 2.1.1 + dayjs: 1.11.19 + goober: 2.1.16(csstype@3.2.3) + solid-js: 1.9.13 + transitivePeerDependencies: + - csstype + + '@tanstack/devtools@0.12.4(csstype@3.2.3)(solid-js@1.9.13)': + dependencies: + '@solid-primitives/event-listener': 2.4.5(solid-js@1.9.13) + '@solid-primitives/keyboard': 1.3.5(solid-js@1.9.13) + '@solid-primitives/resize-observer': 2.1.5(solid-js@1.9.13) + '@tanstack/devtools-client': 0.0.7 + '@tanstack/devtools-event-bus': 0.4.2 + '@tanstack/devtools-ui': 0.5.3(csstype@3.2.3)(solid-js@1.9.13) + clsx: 2.1.1 + goober: 2.1.16(csstype@3.2.3) + solid-js: 1.9.13 + transitivePeerDependencies: + - bufferutil + - csstype + - utf-8-validate + '@tanstack/history@1.154.14': {} '@tanstack/query-core@5.90.20': {} '@tanstack/query-devtools@5.93.0': {} + '@tanstack/react-devtools@0.10.7(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(csstype@3.2.3)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.13)': + dependencies: + '@tanstack/devtools': 0.12.4(csstype@3.2.3)(solid-js@1.9.13) + '@types/react': 19.2.10 + '@types/react-dom': 19.2.3(@types/react@19.2.10) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + transitivePeerDependencies: + - bufferutil + - csstype + - solid-js + - utf-8-validate + '@tanstack/react-query-devtools@5.91.3(@tanstack/react-query@5.90.20(react@19.2.7))(react@19.2.7)': dependencies: '@tanstack/query-devtools': 5.93.0 @@ -15975,6 +16137,12 @@ snapshots: smol-toml@1.6.1: {} + solid-js@1.9.13: + dependencies: + csstype: 3.2.3 + seroval: 1.5.0 + seroval-plugins: 1.5.0(seroval@1.5.0) + source-map-js@1.2.1: {} source-map-support@0.5.21: