feat(nuqs-jotai): bridge nuqs URL state into Jotai (#42086)

This commit is contained in:
Stephen Zhou authored and GitHub committed 2026-09-09 16:11:08 +00:00
1 parent 608b1d10e0
commit 7f59b65b6a
14 files changed
+1529

No files matched your search

+136
View File
@@ -0,0 +1,136 @@
# nuqs-jotai
A Jotai projection of **nuqs-owned URL state**. Nuqs owns parsing, typed optimistic
values, URL serialization, throttling/debouncing, history, transitions, and router
integration. This package owns only scoped snapshots and atom commands.
## Define and register a group
`createQueryGroup` returns a composite `atom` and individual `fields` sharing one
nuqs hook and snapshot. Register the **group**, not its atoms, beneath the native
framework adapter. Use a one-field group for a single parameter.
```tsx
import { useAtomValueRawSync, useSetAtom } from 'jotai'
import { debounce, parseAsInteger, parseAsString } from 'nuqs'
import { NuqsAdapter } from 'nuqs/adapters/next/app'
import { createQueryGroup, QueryStateProvider } from 'nuqs-jotai'
const filters = createQueryGroup(
{
search: parseAsString.withDefault('').withOptions({ limitUrlUpdates: debounce(300) }),
page: parseAsInteger.withDefault(1),
},
{ urlKeys: { search: 'query' } },
)
function Search() {
const search = useAtomValueRawSync(filters.fields.search)
const setSearch = useSetAtom(filters.fields.search)
return <input value={search} onChange={(event) => void setSearch(event.target.value)} />
}
function App() {
return (
<NuqsAdapter>
<QueryStateProvider groups={[filters]}>
<Search />
</QueryStateProvider>
</NuqsAdapter>
)
}
```
Keep group definitions and registration stable for the boundary's lifetime.
Register at the route or application boundary that owns their lifetime. Only the
bridge's snapshot and command primitives are scoped; parent application atoms
remain live. Do not explicitly scope the group's atoms in descendant feature
scopes. Unregistered reads and writes throw.
## Composite and field commands
Use the composite atom to patch related parameters together, or a field atom for
an individual update. Both write through the same nuqs setter:
```ts
await store.set(filters.atom, { search: 'hello', page: 1 })
await store.set(filters.fields.page, (page) => page + 1, { history: 'push' })
await store.set(filters.atom, null) // Clear the group and restore reader defaults.
```
Composite atoms accept partial updates or `null`; field atoms accept a value or
`null`. Both accept functional updates and per-write nuqs options. Field atoms
select individual values, so unrelated fields do not notify their subscribers.
**A group is the snapshot consistency boundary.** Define fields read together by
a derived atom or query in one group. A multi-field update then reaches that
group's derived subscribers as one complete snapshot. Independently created groups
publish separately: even one native nuqs write updating both keys can notify a
cross-group subscriber with `[newSearch, oldPage]` before the final pair.
Registering groups in the same provider does not merge them into a transaction.
Use ordinary `useSetAtom`, `store.set`, and write atoms for commands. The atom
snapshot reflects nuqs's next React render; a `get` immediately after `set` in the
same command need not read a new value. Functional updates are passed directly to
nuqs and follow its batching semantics. A write promise is nuqs's URL-commit
promise, not a React-render or server-render completion signal. Handle rejections
at the command owner.
## Read with Jotai's synchronous API
Use Jotai 3's `useAtomValueRawSync` for URL atoms **and derived atoms depending on
them**. It checks the snapshot on render and subscription, including the first
render after Activity reveal. Default `useAtomValue` / `useAtom` subscriptions do
not provide this check. This package does not wrap or rename Jotai's read APIs.
**Raw means async atoms return a Promise.** Resolve it with React's `use` beneath
Suspense, for example `use(useAtomValueRawSync(asyncResultAtom))`. Synchronous
subscriptions trade concurrent rendering for snapshot consistency. A URL write's
`startTransition` still delegates navigation to nuqs; it does not make these atom
subscriptions concurrent.
The bridge force-hydrates scoped external inputs during render. Jotai warns that
forced hydration can behave incorrectly during concurrent rendering. Scope
isolation does not remove that limitation: interrupted renders and framework
navigation with Suspense need application-level validation before migration.
## Native nuqs interoperability and lifecycle
Native `useQueryState` / `useQueryStates` consumers share nuqs's typed updates and
URL queues with these groups. Use compatible parsers for the same URL key, as
required by nuqs. Separate Jotai scopes do not create independent URL queues.
Provider defaults and `processUrlSearchParams` belong on the native NuqsAdapter.
Option precedence, native-array encoding, typed values, pending promises, and
navigation cancellation follow the installed nuqs version.
The bridge holds mount/reveal commands until nuqs subscribes, then forwards them
in a microtask after the effect pass so other groups and native readers can
subscribe first. Commands arriving while writes are buffered join the buffer in
order. Cleanup cancels stale drains; unmount rejects unforwarded commands and
saved writers throw. Commands already forwarded follow nuqs's lifetime behavior.
Attached writers refresh during commit before descendant layout effects, so
update-time layout commands use current adapter defaults and URL processing.
## Testing and migration
`QueryTestingAdapter` from `nuqs-jotai/testing` wraps the real NuqsTestingAdapter
and accepts its props plus `groups`. Use `hasMemory` to check committed URL state
and wrap React-driven writes in Testing Library `act`.
```tsx
<QueryTestingAdapter groups={[filters]} searchParams="query=hello" hasMemory>
<Search />
</QueryTestingAdapter>
```
This private, unmerged package replaces its earlier atom factories with
`createQueryGroup`. Use `.atom` for composite access and `.fields` for individual
access, change provider `atoms` to `groups`, and replace `useQueryAtomValue` with
Jotai's `useAtomValueRawSync`. Replace `useQueryAtom` with separate
`useAtomValueRawSync` and `useSetAtom` calls.
Run `pnpm --filter nuqs-jotai test run` and
`pnpm --filter nuqs-jotai type-check`. Tests cover shared composite/field access,
native nuqs interoperability, mount/reveal subscriptions, typed snapshots, scope
interoperability, SSR, and shared behavior tests against native nuqs. Running-app
Next/Vinext navigation and server rendering require integration validation.
+43
View File
@@ -0,0 +1,43 @@
{
"name": "nuqs-jotai",
"version": "0.0.0-private",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./src/index.tsx",
"import": "./src/index.tsx"
},
"./testing": {
"types": "./src/testing.tsx",
"import": "./src/testing.tsx"
}
},
"scripts": {
"test": "vp test",
"type-check": "tsc"
},
"devDependencies": {
"@dify/tsconfig": "workspace:*",
"@testing-library/react": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@typescript/native": "catalog:",
"happy-dom": "catalog:",
"jotai": "catalog:",
"jotai-scope": "catalog:",
"nuqs": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vite-plus": "catalog:",
"vitest": "catalog:"
},
"peerDependencies": {
"jotai": "catalog:",
"jotai-scope": "catalog:",
"nuqs": "catalog:",
"react": "catalog:"
}
}
+58
View File
@@ -0,0 +1,58 @@
import type { SetValues, UseQueryStatesKeysMap } from 'nuqs'
type QueryWriter = SetValues<UseQueryStatesKeysMap>
export type Binding = {
write: QueryWriter
refresh: (write: QueryWriter) => void
connect: (write: QueryWriter) => () => void
dispose: () => void
}
/** No URL scheduler: only hold mount-time commands until nuqs has subscribed. */
export function createBinding(): Binding {
let writer: QueryWriter | undefined
let disposed = false
const waiting: {
args: Parameters<QueryWriter>
resolve: (value: URLSearchParams | PromiseLike<URLSearchParams>) => void
reject: (error: unknown) => void
}[] = []
const unavailable = () => new Error('[nuqs-jotai] Cannot write after QueryStateProvider unmounts')
return {
write(...args) {
if (disposed) throw unavailable()
if (writer && waiting.length === 0) return writer(...args)
return new Promise((resolve, reject) => waiting.push({ args, resolve, reject }))
},
refresh(write) {
// Only refresh an attached connection. Mount/reveal commands must still
// wait until nuqs reattaches its passive subscriptions.
if (writer) writer = write
},
connect(write) {
writer = write
let active = true
// Parent and sibling nuqs hooks may not have subscribed yet. Flush held
// commands after this effect pass, preserving their typed emitter payloads.
queueMicrotask(() => {
if (!active || disposed) return
for (const request of waiting.splice(0)) {
try {
request.resolve(write(...request.args))
} catch (error) {
request.reject(error)
}
}
})
return () => {
active = false
writer = undefined
}
},
dispose() {
disposed = true
writer = undefined
for (const request of waiting.splice(0)) request.reject(unavailable())
},
}
}
@@ -0,0 +1,96 @@
import { act, cleanup, render } from '@testing-library/react'
import { atom, useAtomValueRawSync, useStore } from 'jotai'
import { parseAsInteger, useQueryStates } from 'nuqs'
import { useLayoutEffect } from 'react'
import { afterEach, expect, it, vi } from 'vite-plus/test'
import { createQueryGroup } from './index'
import { QueryTestingAdapter } from './testing'
afterEach(cleanup)
it.each(['atoms', 'nuqs'] as const)(
'uses current adapter defaults and processing for update-time layout writes (%s)',
async (implementation) => {
const pageGroup = createQueryGroup({ page: parseAsInteger })
const page = pageGroup.fields.page
const onUrlUpdate = vi.fn()
let command: Promise<URLSearchParams> | undefined
function Writer({ updated }: { updated: boolean }) {
const store = useStore()
const [, nativeWrite] = useQueryStates({ page: parseAsInteger })
useLayoutEffect(() => {
if (updated) {
command = implementation === 'atoms' ? store.set(page, 2) : nativeWrite({ page: 2 })
}
}, [updated, store, nativeWrite])
return null
}
function App({ updated }: { updated: boolean }) {
return (
<QueryTestingAdapter
groups={[pageGroup]}
hasMemory
defaultOptions={{ history: updated ? 'push' : 'replace' }}
processUrlSearchParams={(search) => {
search.set('revision', updated ? 'current' : 'previous')
return search
}}
onUrlUpdate={onUrlUpdate}
>
<Writer updated={updated} />
</QueryTestingAdapter>
)
}
const view = render(<App updated={false} />)
view.rerender(<App updated />)
await act(async () => {
await command
})
expect(onUrlUpdate).toHaveBeenCalledOnce()
const update = onUrlUpdate.mock.calls[0]![0]
expect(update.options.history).toBe('push')
expect(update.searchParams.get('revision')).toBe('current')
},
)
it('publishes related fields as one complete snapshot to derived subscribers', async () => {
const fieldsGroup = createQueryGroup({
a: parseAsInteger.withDefault(0),
b: parseAsInteger.withDefault(0),
})
const fields = fieldsGroup.fields
const pair = atom((get) => [get(fields.a), get(fields.b)])
let store!: ReturnType<typeof useStore>
let write!: ReturnType<
typeof useQueryStates<{ a: typeof parseAsInteger; b: typeof parseAsInteger }>
>[1]
function Capture() {
store = useStore()
const [, nativeWrite] = useQueryStates({ a: parseAsInteger, b: parseAsInteger })
write = nativeWrite
useAtomValueRawSync(pair)
return null
}
render(
<QueryTestingAdapter groups={[fieldsGroup]} hasMemory>
<Capture />
</QueryTestingAdapter>,
)
const seen: number[][] = []
const unsubscribe = store.sub(pair, () => seen.push(store.get(pair)))
try {
await act(async () => {
await write({ a: 1, b: 1 })
})
expect(seen).toEqual([[1, 1]])
await act(async () => {
await store.set(fieldsGroup.atom, { a: 2, b: 2 })
})
expect(seen).toEqual([
[1, 1],
[2, 2],
])
} finally {
unsubscribe()
}
})
@@ -0,0 +1,222 @@
import type { Options, SetValues, UseQueryStatesKeysMap, UseQueryStatesOptions, Values } from 'nuqs'
import type { UrlUpdateEvent } from 'nuqs/adapters/testing'
import { act, cleanup, render } from '@testing-library/react'
import { useStore } from 'jotai'
import {
createParser,
debounce,
parseAsInteger,
parseAsIsoDate,
parseAsString,
throttle,
useQueryStates,
} from 'nuqs'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vite-plus/test'
import { createQueryGroup, QueryStateProvider } from './index'
// nuqs retains its last-flush timestamp in a module singleton, so keep the
// fake monotonic clock running across cases just as the browser clock does.
beforeAll(() => vi.useFakeTimers())
afterEach(cleanup)
afterAll(() => vi.useRealTimers())
// Run identical consumer expectations against nuqs and the atom bridge.
// This protects the contract when either implementation or nuqs is upgraded.
describe.each(['nuqs', 'atoms'] as const)('%s compatibility', (implementation) => {
function mount<P extends UseQueryStatesKeysMap>(
parsers: P,
search = '',
options: Partial<UseQueryStatesOptions<P>> = {},
defaults: Options = {},
) {
const onUpdate = vi.fn<(event: UrlUpdateEvent) => void>()
let read!: () => Values<P>
let write!: SetValues<P>
const queryAtomGroup = createQueryGroup(parsers, options)
const queryAtom = queryAtomGroup.atom
function AtomCapture() {
const store = useStore()
read = () => store.get(queryAtom)
write = (...args) => store.set(queryAtom, ...args)
return null
}
function NuqsCapture() {
const [value, setter] = useQueryStates(parsers, options)
read = () => value
write = setter
return null
}
function Wrapper({ query }: { query: string }) {
return (
<NuqsTestingAdapter
hasMemory
rateLimitFactor={1}
searchParams={query}
defaultOptions={defaults}
onUrlUpdate={onUpdate}
>
{implementation === 'atoms' ? (
<QueryStateProvider groups={[queryAtomGroup]}>
<AtomCapture />
</QueryStateProvider>
) : (
<NuqsCapture />
)}
</NuqsTestingAdapter>
)
}
const view = render(<Wrapper query={search} />)
const navigate = (query: string) => view.rerender(<Wrapper query={query} />)
return {
read: () => read(),
write: (...args: Parameters<SetValues<P>>) => {
let result!: Promise<URLSearchParams>
act(() => {
result = write(...args)
})
return result
},
navigate,
onUpdate,
}
}
async function advance(time?: number) {
await act(async () => {
if (time === undefined) await vi.runAllTimersAsync()
else await vi.advanceTimersByTimeAsync(time)
})
}
it('retains typed Date values before and after committing a lossy serialization', async () => {
const { read, write } = mount({ date: parseAsIsoDate })
const date = new Date('2026-09-09T12:34:56.000Z')
const first = write({ date })
expect(read().date).toBe(date)
await advance()
expect((await first).get('date')).toBe('2026-09-09')
expect(read().date).toBe(date)
// Same URL, different typed value: a href-only atom would miss this update.
const second = write(({ date }) => ({ date: new Date(date!.getTime() + 3600000) }))
expect(read().date?.toISOString()).toBe('2026-09-09T13:34:56.000Z')
await advance()
await second
expect(read().date?.toISOString()).toBe('2026-09-09T13:34:56.000Z')
})
it('evaluates consecutive functional updates without rounding their state', async () => {
const parser = createParser({ parse: Number, serialize: (value: number) => value.toFixed(2) })
const { read, write } = mount({ value: parser })
const first = write({ value: 1.234 })
const second = write(({ value }) => ({ value: value! + 0.001 }))
expect(read().value).toBeCloseTo(1.235, 12)
await advance()
await Promise.all([first, second])
expect(read().value).toBeCloseTo(1.235, 12)
})
it('does not parse or replace an object when unrelated URL keys change', async () => {
const parse = vi.fn((query: string) => JSON.parse(query) as { x: number })
const parser = createParser({ parse, serialize: JSON.stringify })
const { read, write, navigate } = mount(
{ data: parser, page: parseAsInteger },
'payload=%7B%22x%22%3A1%7D',
{ urlKeys: { data: 'payload' } },
)
const before = read().data
parse.mockClear()
const update = write({ page: 2 })
await advance()
await update
expect(read().data).toBe(before)
navigate('payload=%7B%22x%22%3A1%7D&page=3')
expect(read().data).toBe(before)
expect(parse).not.toHaveBeenCalled()
navigate('payload=%7B%22x%22%3A2%7D&page=3')
expect(read().data).toEqual({ x: 2 })
expect(read().data).not.toBe(before)
expect(parse).toHaveBeenCalledOnce()
})
it('resolves write, parser, factory and provider options in that order', async () => {
const { write, onUpdate } = mount(
{ q: parseAsString.withOptions({ history: 'push' }) },
'',
{ history: 'replace', shallow: false },
{ history: 'replace', shallow: true, scroll: true },
)
const first = write({ q: 'hello' })
await advance()
await first
expect(onUpdate.mock.calls[0]?.[0].options).toEqual({
history: 'push',
shallow: false,
scroll: true,
})
const second = write({ q: 'world' }, { history: 'replace', shallow: true, scroll: false })
await advance()
await second
expect(onUpdate.mock.calls[1]?.[0].options).toEqual({
history: 'replace',
shallow: true,
scroll: false,
})
})
it('uses the longest throttle in a batch relative to the last commit', async () => {
const { write, onUpdate } = mount({ q: parseAsString })
const seed = write({ q: 'seed' })
await advance()
await seed
onUpdate.mockClear()
const slow = write({ q: 'slow' }, { limitUrlUpdates: throttle(1000) })
const fast = write({ q: 'fast' }, { limitUrlUpdates: throttle(50) })
await advance(999)
expect(onUpdate).not.toHaveBeenCalled()
await advance(1)
await Promise.all([slow, fast])
expect(onUpdate).toHaveBeenCalledOnce()
expect(onUpdate.mock.calls[0]?.[0].searchParams.get('q')).toBe('fast')
await advance(5000)
const idle = write({ q: 'after idle' }, { limitUrlUpdates: throttle(1000) })
await advance(0)
await idle
expect(onUpdate).toHaveBeenCalledTimes(2)
})
it('debounces each key independently even with push history', async () => {
const { write, onUpdate } = mount({
a: parseAsString.withOptions({ limitUrlUpdates: debounce(500), history: 'push' }),
b: parseAsString.withOptions({ limitUrlUpdates: debounce(1500) }),
})
const first = write({ a: 'first', b: 'other' })
const settled = vi.fn()
void first.then(settled)
await advance(200)
const second = write({ a: 'latest' })
await advance(499)
expect(onUpdate).not.toHaveBeenCalled()
// A debounce enters nuqs's throttle queue on a subsequent event-loop tick.
await advance(2)
expect(onUpdate).toHaveBeenCalledOnce()
expect(onUpdate.mock.calls[0]?.[0].searchParams.get('a')).toBe('latest')
expect(onUpdate.mock.calls[0]?.[0].searchParams.has('b')).toBe(false)
expect(settled).not.toHaveBeenCalled()
await advance(800)
expect(onUpdate).toHaveBeenCalledTimes(2)
expect(onUpdate.mock.calls[1]?.[0].searchParams.get('b')).toBe('other')
await Promise.all([first, second])
})
it('wraps the URL commit in the supplied transition', async () => {
const transition = vi.fn((action: () => void) => action())
const { write, onUpdate } = mount({ q: parseAsString })
const result = write({ q: 'hello' }, { startTransition: transition, shallow: false })
expect(transition).not.toHaveBeenCalled()
await advance()
await result
expect(transition).toHaveBeenCalledOnce()
expect(onUpdate).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,49 @@
import { act } from '@testing-library/react'
import { useAtomValueRawSync } from 'jotai'
import { parseAsInteger } from 'nuqs'
import { NuqsAdapter } from 'nuqs/adapters/react'
import { useLayoutEffect } from 'react'
import { hydrateRoot } from 'react-dom/client'
import { renderToString } from 'react-dom/server'
import { expect, it, vi } from 'vite-plus/test'
import { createQueryGroup, QueryStateProvider } from './index'
it('hydrates from the server snapshot and then adopts the current browser URL', async () => {
window.history.replaceState(null, '', '/?page=7')
const pageGroup = createQueryGroup({ page: parseAsInteger.withDefault(1) })
const page = pageGroup.fields.page
const seen: number[] = []
function Reader() {
const value = useAtomValueRawSync(page)
useLayoutEffect(() => {
seen.push(value)
}, [value])
return <p>{value}</p>
}
function App() {
return (
<NuqsAdapter serverSearch="page=1">
<QueryStateProvider groups={[pageGroup]}>
<Reader />
</QueryStateProvider>
</NuqsAdapter>
)
}
const container = document.createElement('div')
container.innerHTML = renderToString(<App />)
document.body.append(container)
const onRecoverableError = vi.fn()
let root!: ReturnType<typeof hydrateRoot>
try {
expect(container.textContent).toBe('1')
await act(async () => {
root = hydrateRoot(container, <App />, { onRecoverableError })
})
expect(onRecoverableError).not.toHaveBeenCalled()
expect(seen[0]).toBe(1)
expect(container.textContent).toBe('7')
} finally {
act(() => root?.unmount())
container.remove()
}
})
+272
View File
@@ -0,0 +1,272 @@
import type { ExtractAtomValue } from 'jotai'
import type { ComponentProps } from 'react'
import type { QueryStateProvider } from './index'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { atom, createStore, Provider, useAtomValueRawSync, useSetAtom, useStore } from 'jotai'
import { ScopeProvider } from 'jotai-scope'
import { parseAsInteger, parseAsString, useQueryState } from 'nuqs'
import { renderToString } from 'react-dom/server'
import { afterEach, expect, expectTypeOf, it, vi } from 'vite-plus/test'
import { createQueryGroup } from './index'
import { QueryTestingAdapter } from './testing'
afterEach(cleanup)
it('reads URL state and commits using the surrounding nuqs adapter', async () => {
const pageGroup = createQueryGroup({ page: parseAsInteger.withDefault(1) })
const page = pageGroup.fields.page
const onUrlUpdate = vi.fn()
let commit!: Promise<URLSearchParams>
function Counter() {
const value = useAtomValueRawSync(page)
const set = useSetAtom(page)
return (
<button
onClick={() => {
commit = set((value) => value + 1)
}}
>
{value}
</button>
)
}
render(
<QueryTestingAdapter
groups={[pageGroup]}
searchParams="page=2"
hasMemory
onUrlUpdate={onUrlUpdate}
>
<Counter />
</QueryTestingAdapter>,
)
expect(screen.getByRole('button').textContent).toBe('2')
await act(async () => {
fireEvent.click(screen.getByRole('button'))
await commit
})
expect(screen.getByRole('button').textContent).toBe('3')
expect(onUrlUpdate.mock.calls[0]?.[0].searchParams.get('page')).toBe('3')
})
it('preserves parent application state and derived atom interoperability across feature scopes', async () => {
const workspace = atom('first')
const local = atom(false)
const pageGroup = createQueryGroup({ page: parseAsInteger.withDefault(1) })
const page = pageGroup.fields.page
const label = atom((get) => `${get(workspace)}:${get(page)}`)
const parent = createStore()
function Reader() {
return <p>{useAtomValueRawSync(label)}</p>
}
render(
<Provider store={parent}>
<QueryTestingAdapter groups={[pageGroup]} searchParams="page=2">
<ScopeProvider atoms={[local]}>
<Reader />
</ScopeProvider>
</QueryTestingAdapter>
</Provider>,
)
expect(screen.getByText('first:2')).toBeDefined()
act(() => parent.set(workspace, 'second'))
expect(screen.getByText('second:2')).toBeDefined()
})
it('retains unregistered parent atoms while isolating registered snapshots', () => {
const pageGroup = createQueryGroup({ page: parseAsInteger.withDefault(1) })
const page = pageGroup.fields.page
function Reader() {
return <p>{useAtomValueRawSync(page)}</p>
}
render(
<Provider>
<QueryTestingAdapter groups={[pageGroup]} searchParams="page=2">
<Reader />
</QueryTestingAdapter>
<QueryTestingAdapter groups={[pageGroup]} searchParams="page=7">
<Reader />
</QueryTestingAdapter>
</Provider>,
)
expect(screen.getByText('2')).toBeDefined()
expect(screen.getByText('7')).toBeDefined()
})
it('supports alias groups, partial updates, resets, and factory options', async () => {
const queryGroup = createQueryGroup(
{ page: parseAsInteger.withDefault(1), q: parseAsString },
{ urlKeys: { q: 'search' }, history: 'push' },
)
const group = queryGroup.atom
let store!: ReturnType<typeof useStore>
function Capture() {
store = useStore()
return <p>{JSON.stringify(useAtomValueRawSync(group))}</p>
}
const onUrlUpdate = vi.fn()
render(
<QueryTestingAdapter groups={[queryGroup]} hasMemory onUrlUpdate={onUrlUpdate}>
<Capture />
</QueryTestingAdapter>,
)
await act(async () => {
await store.set(group, { q: 'hello', page: 3 })
})
expect(store.get(group)).toEqual({ page: 3, q: 'hello' })
expect(onUrlUpdate.mock.calls[0]?.[0].queryString).toContain('search=hello')
expect(onUrlUpdate.mock.calls[0]?.[0].options.history).toBe('push')
await act(async () => {
await store.set(group, null)
})
expect(store.get(group)).toEqual({ page: 1, q: null })
})
it('shares composite and field access while keeping unrelated field subscribers quiet', async () => {
const fieldsGroup = createQueryGroup({ page: parseAsInteger.withDefault(1), q: parseAsString })
const fields = fieldsGroup.fields
let store!: ReturnType<typeof useStore>
const observed = vi.fn()
function Capture() {
store = useStore()
return null
}
render(
<QueryTestingAdapter groups={[fieldsGroup]} hasMemory>
<Capture />
</QueryTestingAdapter>,
)
const unsubscribe = store.sub(fields.q, observed)
await act(async () => {
await store.set(fields.page, (value) => value + 1)
})
expect(store.get(fields.page)).toBe(2)
expect(observed).not.toHaveBeenCalled()
expect(store.get(fieldsGroup.atom)).toEqual({ page: 2, q: null })
await act(async () => {
await store.set(fieldsGroup.atom, (current) => ({ page: current.page + 1, q: 'hello' }))
})
expect(store.get(fields.page)).toBe(3)
expect(store.get(fields.q)).toBe('hello')
expect(observed).toHaveBeenCalledOnce()
await act(async () => {
await store.set(fields.page, null)
})
expect(store.get(fieldsGroup.atom)).toEqual({ page: 1, q: 'hello' })
expect(observed).toHaveBeenCalledOnce()
unsubscribe()
})
it('updates native nuqs readers immediately from atom commands', async () => {
const pageGroup = createQueryGroup({ page: parseAsInteger.withDefault(1) })
const page = pageGroup.fields.page
let write!: ReturnType<typeof useSetAtom<typeof page>>
function Capture() {
write = useSetAtom(page)
const [value] = useQueryState('page', parseAsInteger)
return <p>{value}</p>
}
render(
<QueryTestingAdapter groups={[pageGroup]} searchParams="page=1" hasMemory>
<Capture />
</QueryTestingAdapter>,
)
let commit!: Promise<URLSearchParams>
act(() => {
commit = write(2)
})
expect(screen.getByText('2')).toBeDefined()
await act(async () => {
await commit
})
})
it('uses nuqs provider processing and current default options', async () => {
const pageGroup = createQueryGroup({ page: parseAsInteger })
const page = pageGroup.fields.page
let store!: ReturnType<typeof useStore>
function Capture() {
store = useStore()
return null
}
const onUrlUpdate = vi.fn()
function App({ history }: { history: 'push' | 'replace' }) {
return (
<QueryTestingAdapter
groups={[pageGroup]}
hasMemory
defaultOptions={{ history }}
onUrlUpdate={onUrlUpdate}
processUrlSearchParams={(search) => {
search.set('processed', 'yes')
return search
}}
>
<Capture />
</QueryTestingAdapter>
)
}
const view = render(<App history="replace" />)
await act(async () => {
await store.set(page, 1)
})
view.rerender(<App history="push" />)
await act(async () => {
await store.set(page, 2)
})
expect(onUrlUpdate.mock.calls.at(-1)?.[0].options.history).toBe('push')
expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get('processed')).toBe('yes')
})
it('renders the adapter server snapshot without a browser runtime', () => {
const pageGroup = createQueryGroup({ page: parseAsInteger.withDefault(1) })
const page = pageGroup.fields.page
function Reader() {
return <p>{useAtomValueRawSync(page)}</p>
}
const html = renderToString(
<QueryTestingAdapter groups={[pageGroup]} searchParams="page=8">
<Reader />
</QueryTestingAdapter>,
)
expect(html).toContain('8')
})
it('requires explicit registration instead of silently reading a default', () => {
const pageGroup = createQueryGroup({ page: parseAsInteger.withDefault(1) })
const page = pageGroup.fields.page
const store = createStore()
expect(() => store.get(page)).toThrow('Register this group')
expect(() => store.set(page, 2)).toThrow('Register this group')
})
it('rejects saved writers after their bridge unmounts', () => {
const pageGroup = createQueryGroup({ page: parseAsInteger })
const page = pageGroup.fields.page
let store!: ReturnType<typeof useStore>
function Capture() {
store = useStore()
return null
}
const view = render(
<QueryTestingAdapter groups={[pageGroup]}>
<Capture />
</QueryTestingAdapter>,
)
view.unmount()
expect(() => store.set(page, 2)).toThrow('unmounts')
})
it('provides a non-null typed value when the parser has a default', () => {
const pageGroup = createQueryGroup({ page: parseAsInteger.withDefault(1) })
const page = pageGroup.fields.page
const nullableGroup = createQueryGroup({ page: parseAsInteger })
const nullable = nullableGroup.fields.page
expectTypeOf<ExtractAtomValue<typeof page>>().toEqualTypeOf<number>()
expectTypeOf<ExtractAtomValue<typeof nullable>>().toEqualTypeOf<number | null>()
expectTypeOf<ExtractAtomValue<typeof pageGroup.atom>>().toEqualTypeOf<{ page: number }>()
type Registration = ComponentProps<typeof QueryStateProvider>['groups'][number]
expectTypeOf<typeof pageGroup>().toExtend<Registration>()
expectTypeOf<typeof page>().not.toExtend<Registration>()
expectTypeOf<typeof pageGroup.atom>().not.toExtend<Registration>()
})
+146
View File
@@ -0,0 +1,146 @@
'use client'
import type { WritableAtom } from 'jotai'
import type { Options, SetValues, UseQueryStatesKeysMap, UseQueryStatesOptions, Values } from 'nuqs'
import type { ReactNode } from 'react'
import type { Binding } from './binding'
import { atom } from 'jotai'
import { ScopeProvider } from 'jotai-scope'
import { atomWithLazy, useHydrateAtoms } from 'jotai/utils'
import { useQueryStates } from 'nuqs'
import { useEffect, useInsertionEffect, useState } from 'react'
import { createBinding } from './binding'
const definitionKey = Symbol('nuqs-jotai.definition')
type KeyMap = UseQueryStatesKeysMap
type Definition = {
parsers: KeyMap
options: Partial<UseQueryStatesOptions<KeyMap>>
valueAtom: WritableAtom<Values<KeyMap>, [Values<KeyMap>], void>
bindingAtom: WritableAtom<Binding, [Binding], void>
}
type Registered = { readonly [definitionKey]: Definition }
export type QueryAtom<Value> = WritableAtom<
Value,
[update: Value | null | ((previous: Value) => Value | null), options?: Options],
Promise<URLSearchParams>
>
export type SearchParamsAtom<P extends KeyMap> = WritableAtom<
Values<P>,
Parameters<SetValues<P>>,
Promise<URLSearchParams>
>
export type SearchParamsOptions<P extends KeyMap> = Options & {
urlKeys?: UseQueryStatesOptions<P>['urlKeys']
debugLabel?: string
}
export type QueryGroup<P extends KeyMap> = Registered & {
readonly atom: SearchParamsAtom<P>
readonly fields: { readonly [K in keyof P]: QueryAtom<Values<P>[K]> }
}
/** One nuqs hook and snapshot shared by a composite atom and its field atoms. */
export function createQueryGroup<P extends KeyMap>(
parsers: P,
{ debugLabel = 'query', ...options }: SearchParamsOptions<P> = {},
): QueryGroup<P> {
const valueAtom = atom<Values<KeyMap>>({})
const bindingAtom = atomWithLazy<Binding>(() => {
throw new Error('[nuqs-jotai] Register this group in QueryStateProvider')
})
const definition: Definition = { parsers, options, valueAtom, bindingAtom }
const queryAtom = atom(
(get) => {
get(bindingAtom)
return get(valueAtom) as Values<P>
},
(get, _set, ...args: Parameters<SetValues<P>>) => {
const binding = get(bindingAtom)
return (binding.write as SetValues<P>)(...args)
},
)
queryAtom.debugLabel = debugLabel
valueAtom.debugLabel = `${debugLabel}.snapshot`
bindingAtom.debugLabel = `${debugLabel}.binding`
function forKey<K extends keyof P>(key: K): QueryAtom<Values<P>[K]> {
const field = atom(
(get) => get(queryAtom)[key],
(
_get,
set,
update: Values<P>[K] | null | ((previous: Values<P>[K]) => Values<P>[K] | null),
writeOptions?: Options,
) =>
set(
queryAtom,
(current) =>
({
[key]:
typeof update === 'function'
? (update as (previous: Values<P>[K]) => Values<P>[K] | null)(current[key])
: update,
}) as Partial<Values<P>>,
writeOptions,
),
)
field.debugLabel = `${debugLabel}.${String(key)}`
return field
}
return {
atom: queryAtom,
fields: Object.fromEntries(
Object.keys(parsers).map((key) => [key, forKey(key)]),
) as QueryGroup<P>['fields'],
[definitionKey]: definition,
}
}
/** Mount beneath the framework's NuqsAdapter. Register groups once per URL scope. */
export function QueryStateProvider({
groups,
children,
}: {
groups: readonly Registered[]
children: ReactNode
}) {
const definitions = [...new Set(groups.map((group) => group[definitionKey]))]
return (
<ScopeProvider
atoms={definitions.flatMap((definition) => [definition.valueAtom, definition.bindingAtom])}
name="QueryState"
>
{definitions.reduceRight<ReactNode>(
(content, definition) => (
<Bridge key={definition.valueAtom.toString()} definition={definition}>
{content}
</Bridge>
),
children,
)}
</ScopeProvider>
)
}
function Bridge({ definition, children }: { definition: Definition; children: ReactNode }) {
const [values, write] = useQueryStates(definition.parsers, definition.options)
const [binding] = useState(createBinding)
// This scope owns only authoritative nuqs inputs. Never hydrate consumer drafts.
useHydrateAtoms(
[
[definition.valueAtom, values],
[definition.bindingAtom, binding],
],
{ dangerouslyForceHydrate: true },
)
useInsertionEffect(() => () => binding.dispose(), [binding])
// Refresh before descendant layout commands run, without publishing a
// speculative render's writer or attaching mount/reveal connections early.
useInsertionEffect(() => binding.refresh(write), [binding, write])
// Connect after this hook subscribes; held commands wait for the effect pass.
useEffect(() => binding.connect(write), [binding, write])
return children
}
@@ -0,0 +1,310 @@
import type { ReactNode } from 'react'
import { act, cleanup, render, screen } from '@testing-library/react'
import { useAtomValueRawSync, useSetAtom } from 'jotai'
import {
debounce,
parseAsInteger,
parseAsIsoDate,
parseAsString,
throttle,
useQueryState,
} from 'nuqs'
import { enableHistorySync, NuqsAdapter } from 'nuqs/adapters/react'
import { Activity, StrictMode, useLayoutEffect } from 'react'
import { afterEach, expect, it, vi } from 'vite-plus/test'
import { createQueryGroup, QueryStateProvider } from './index'
import { QueryTestingAdapter } from './testing'
afterEach(() => {
act(() => window.dispatchEvent(new PopStateEvent('popstate')))
cleanup()
vi.useRealTimers()
})
it.each([
{ reverse: false, delay: 0 },
{ reverse: true, delay: 0 },
{ reverse: false, delay: Infinity },
{ reverse: true, delay: Infinity },
])(
'shares typed mount and reveal writes across groups ($reverse, $delay)',
async ({ reverse, delay }) => {
vi.useFakeTimers()
window.history.replaceState(null, '', '/?day=2026-09-09')
const firstGroup = createQueryGroup({ date: parseAsIsoDate }, { urlKeys: { date: 'day' } })
const first = firstGroup.atom
const secondGroup = createQueryGroup({ day: parseAsIsoDate })
const second = secondGroup.fields.day
let date = new Date('2026-09-09T12:34:56.000Z')
function NativeReader({ children }: { children?: ReactNode }) {
const [value] = useQueryState('day', parseAsIsoDate)
return (
<>
<p data-testid="native">{value?.toISOString()}</p>
{children}
</>
)
}
function Consumer() {
const write = useSetAtom(second)
const a = useAtomValueRawSync(first).date
const b = useAtomValueRawSync(second)
useLayoutEffect(() => {
void write(date, { limitUrlUpdates: throttle(delay) })
}, [write])
return (
<>
<p data-testid="first">{a?.toISOString()}</p>
<p data-testid="second">{b?.toISOString()}</p>
<NativeReader />
</>
)
}
function App({ hidden }: { hidden: boolean }) {
return (
<StrictMode>
<Activity mode={hidden ? 'hidden' : 'visible'}>
<NuqsAdapter>
<NativeReader>
<QueryStateProvider
groups={reverse ? [secondGroup, firstGroup] : [firstGroup, secondGroup]}
>
<Consumer />
</QueryStateProvider>
</NativeReader>
</NuqsAdapter>
</Activity>
</StrictMode>
)
}
const view = render(<App hidden={false} />)
async function expectSharedDate() {
await act(async () => {
await vi.runAllTimersAsync()
})
expect(screen.getByTestId('first').textContent).toBe(date.toISOString())
expect(screen.getByTestId('second').textContent).toBe(date.toISOString())
for (const reader of screen.getAllByTestId('native'))
expect(reader.textContent).toBe(date.toISOString())
}
await expectSharedDate()
view.rerender(<App hidden />)
date = new Date('2026-09-10T15:00:00.000Z')
window.history.replaceState(null, '', '/?day=2026-09-10')
view.rerender(<App hidden={false} />)
await expectSharedDate()
},
)
it('rejects buffered mount commands when unmounted before the effect-pass drain', async () => {
const pageGroup = createQueryGroup({ page: parseAsInteger })
const page = pageGroup.fields.page
const onUrlUpdate = vi.fn()
let command!: Promise<URLSearchParams>
function Writer() {
const write = useSetAtom(page)
useLayoutEffect(() => {
command = write(2)
}, [write])
return null
}
const view = render(
<QueryTestingAdapter groups={[pageGroup]} onUrlUpdate={onUrlUpdate}>
<Writer />
</QueryTestingAdapter>,
)
const rejected = expect(command).rejects.toThrow('unmounts')
view.unmount()
await rejected
expect(onUrlUpdate).not.toHaveBeenCalled()
})
it.each([0, Infinity])(
'observes a descendant layout write on mount (throttle %s)',
async (delay) => {
vi.useFakeTimers()
window.history.replaceState(null, '', '/?page=1')
const pageGroup = createQueryGroup({ page: parseAsInteger.withDefault(1) })
const page = pageGroup.fields.page
function Reader() {
return <p>Page {useAtomValueRawSync(page)}</p>
}
function Writer() {
const set = useSetAtom(page)
useLayoutEffect(() => {
void set(2, { limitUrlUpdates: throttle(delay) })
}, [set])
return null
}
render(
<StrictMode>
<NuqsAdapter>
<QueryStateProvider groups={[pageGroup]}>
<Reader />
<Writer />
</QueryStateProvider>
</NuqsAdapter>
</StrictMode>,
)
await act(async () => {
await vi.runAllTimersAsync()
})
expect(screen.getByText('Page 2')).toBeDefined()
},
)
it('reads the current URL in the first layout effect after Activity reveal', async () => {
const pageGroup = createQueryGroup({ page: parseAsInteger.withDefault(1) })
const page = pageGroup.fields.page
window.history.replaceState(null, '', '/?page=1')
const seen: number[] = []
function Reader() {
const value = useAtomValueRawSync(page)
useLayoutEffect(() => {
seen.push(value)
}, [])
return <p>Page {value}</p>
}
function App({ hidden }: { hidden: boolean }) {
return (
<Activity mode={hidden ? 'hidden' : 'visible'}>
<NuqsAdapter>
<QueryStateProvider groups={[pageGroup]}>
<Reader />
</QueryStateProvider>
</NuqsAdapter>
</Activity>
)
}
const view = render(<App hidden={false} />)
view.rerender(<App hidden />)
await act(async () => {
window.history.replaceState(null, '', '/?page=7')
})
seen.length = 0
view.rerender(<App hidden={false} />)
expect(screen.getByText('Page 7')).toBeDefined()
expect(seen).toEqual([7])
})
it.each(['nuqs-first', 'atom-first'] as const)(
'shares one queue with nuqs during debounce and push (%s)',
async (order) => {
vi.useFakeTimers()
window.history.replaceState(null, '', '/?page=1')
enableHistorySync()
const pageGroup = createQueryGroup({ page: parseAsInteger.withDefault(1) })
const page = pageGroup.fields.page
const searchGroup = createQueryGroup({ q: parseAsString })
const search = searchGroup.fields.q
let setSearch!: (value: string) => Promise<URLSearchParams>
let setPage!: (value: number) => Promise<URLSearchParams>
function Capture() {
const atomPage = useSetAtom(page)
const atomSearch = useSetAtom(search)
const [, nuqsPage] = useQueryState('page', parseAsInteger)
const [, nuqsSearch] = useQueryState('q', parseAsString)
setSearch = (value) =>
order === 'nuqs-first'
? nuqsSearch(value, { limitUrlUpdates: debounce(500) })
: atomSearch(value, { limitUrlUpdates: debounce(500) })
setPage = (value) =>
order === 'nuqs-first'
? atomPage(value, { history: 'push' })
: nuqsPage(value, { history: 'push' })
return <p>{useAtomValueRawSync(search)}</p>
}
render(
<NuqsAdapter>
<QueryStateProvider groups={[pageGroup, searchGroup]}>
<Capture />
</QueryStateProvider>
</NuqsAdapter>,
)
let pending!: Promise<URLSearchParams>
act(() => {
pending = setSearch('draft')
void setPage(2)
})
expect(screen.getByText('draft')).toBeDefined()
await act(async () => {
await vi.runAllTimersAsync()
await pending
})
expect(new URLSearchParams(window.location.search).get('page')).toBe('2')
expect(new URLSearchParams(window.location.search).get('q')).toBe('draft')
},
)
it('forwards reveal layout commands after nuqs reattaches its subscriptions', async () => {
vi.useFakeTimers()
window.history.replaceState(null, '', '/?page=1')
const pageGroup = createQueryGroup({ page: parseAsInteger.withDefault(1) })
const page = pageGroup.fields.page
function Consumer() {
const value = useAtomValueRawSync(page)
const write = useSetAtom(page)
useLayoutEffect(() => {
void write((current) => current + 1)
}, [write])
return <p>{value}</p>
}
function App({ hidden }: { hidden: boolean }) {
return (
<Activity mode={hidden ? 'hidden' : 'visible'}>
<NuqsAdapter>
<QueryStateProvider groups={[pageGroup]}>
<Consumer />
</QueryStateProvider>
</NuqsAdapter>
</Activity>
)
}
const view = render(<App hidden={false} />)
await act(async () => {
await vi.runAllTimersAsync()
})
expect(screen.getByText('2')).toBeDefined()
view.rerender(<App hidden />)
window.history.replaceState(null, '', '/?page=7')
view.rerender(<App hidden={false} />)
await act(async () => {
await vi.runAllTimersAsync()
})
expect(screen.getByText('8')).toBeDefined()
expect(window.location.search).toBe('?page=8')
})
it('refreshes a hidden reader while its URL bridge remains mounted', async () => {
window.history.replaceState(null, '', '/?page=1')
const pageGroup = createQueryGroup({ page: parseAsInteger.withDefault(1) })
const page = pageGroup.fields.page
const seen: number[] = []
function Consumer() {
const value = useAtomValueRawSync(page)
useLayoutEffect(() => {
seen.push(value)
}, [])
return <p>{value}</p>
}
function App({ hidden }: { hidden: boolean }) {
return (
<NuqsAdapter>
<QueryStateProvider groups={[pageGroup]}>
<Activity mode={hidden ? 'hidden' : 'visible'}>
<Consumer />
</Activity>
</QueryStateProvider>
</NuqsAdapter>
)
}
const view = render(<App hidden={false} />)
view.rerender(<App hidden />)
await act(async () => {
window.history.replaceState(null, '', '/?page=7')
window.dispatchEvent(new PopStateEvent('popstate'))
})
seen.length = 0
view.rerender(<App hidden={false} />)
expect(seen).toEqual([7])
})
+117
View File
@@ -0,0 +1,117 @@
import { act, cleanup, render, screen } from '@testing-library/react'
import { useAtomValueRawSync, useStore } from 'jotai'
import { parseAsIsoDate, parseAsNativeArrayOf, parseAsString } from 'nuqs'
import { afterEach, expect, it } from 'vite-plus/test'
import { createQueryGroup } from './index'
import { QueryTestingAdapter } from './testing'
afterEach(cleanup)
it('notifies aliased readers of typed updates even when the encoded URL stays unchanged', async () => {
const dateAtomGroup = createQueryGroup({ day: parseAsIsoDate })
const dateAtom = dateAtomGroup.fields.day
const aliasAtomGroup = createQueryGroup({ date: parseAsIsoDate }, { urlKeys: { date: 'day' } })
const aliasAtom = aliasAtomGroup.atom
let store!: ReturnType<typeof useStore>
function Reader() {
store = useStore()
const { date } = useAtomValueRawSync(aliasAtom)
return <p>{date?.toISOString()}</p>
}
const view = render(
<QueryTestingAdapter
groups={[dateAtomGroup, aliasAtomGroup]}
searchParams="day=2026-09-09"
hasMemory
>
<Reader />
</QueryTestingAdapter>,
)
const date = new Date('2026-09-09T12:34:56.000Z')
await act(async () => {
await store.set(dateAtom, date)
})
expect(screen.getByText(date.toISOString())).toBeDefined()
expect(store.get(aliasAtom).date).toBe(date)
const later = new Date('2026-09-09T15:00:00.000Z')
await act(async () => {
await store.set(dateAtom, later)
})
expect(screen.getByText(later.toISOString())).toBeDefined()
view.unmount()
render(
<QueryTestingAdapter
groups={[dateAtomGroup, aliasAtomGroup]}
searchParams="day=2026-09-09"
hasMemory
>
<Reader />
</QueryTestingAdapter>,
)
expect(screen.getByText('2026-09-09T00:00:00.000Z')).toBeDefined()
})
it('shares cleared values while retaining each reader default', async () => {
const firstGroup = createQueryGroup({ q: parseAsString.withDefault('first') })
const first = firstGroup.fields.q
const secondGroup = createQueryGroup({ q: parseAsString.withDefault('second') })
const second = secondGroup.fields.q
let store!: ReturnType<typeof useStore>
function Capture() {
store = useStore()
return null
}
render(
<QueryTestingAdapter groups={[firstGroup, secondGroup]} hasMemory>
<Capture />
</QueryTestingAdapter>,
)
await act(async () => {
await store.set(first, 'shared')
})
expect(store.get(second)).toBe('shared')
await act(async () => {
await store.set(second, null)
})
expect(store.get(first)).toBe('first')
expect(store.get(second)).toBe('second')
})
it('inherits nuqs native-array normalization after the URL commits', async () => {
const itemsGroup = createQueryGroup(
{ items: parseAsNativeArrayOf(parseAsString) },
{
clearOnDefault: false,
},
)
const items = itemsGroup.fields.items
let store!: ReturnType<typeof useStore>
function Capture() {
store = useStore()
return null
}
const view = render(
<QueryTestingAdapter groups={[itemsGroup]} hasMemory>
<Capture />
</QueryTestingAdapter>,
)
let commit!: Promise<URLSearchParams>
act(() => {
commit = store.set(items, [])
})
expect(store.get(items)).toEqual([])
await act(async () => {
await commit
})
expect(store.get(items)).toEqual([''])
view.unmount()
render(
<QueryTestingAdapter groups={[itemsGroup]} searchParams="items=" hasMemory>
<Capture />
</QueryTestingAdapter>,
)
expect(store.get(items)).toEqual([''])
})
+17
View File
@@ -0,0 +1,17 @@
import type { ComponentProps } from 'react'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { QueryStateProvider } from './index'
/** The real nuqs test adapter, with the same group registration as production. */
export function QueryTestingAdapter({
groups,
children,
...options
}: ComponentProps<typeof NuqsTestingAdapter> &
Pick<ComponentProps<typeof QueryStateProvider>, 'groups'>) {
return (
<NuqsTestingAdapter {...options}>
<QueryStateProvider groups={groups}>{children}</QueryStateProvider>
</NuqsTestingAdapter>
)
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@dify/tsconfig/react.json",
"compilerOptions": {
"types": ["vite-plus/test/globals", "vite-plus/test/matchers"]
},
"include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts"],
"exclude": ["node_modules", "dist"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite-plus'
export default defineConfig({
test: {
environment: 'happy-dom',
},
})
+48
View File
@@ -1212,6 +1212,54 @@ importers:
specifier: 'catalog:'
version: 0.3.1(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)
packages/nuqs-jotai:
devDependencies:
'@dify/tsconfig':
specifier: workspace:*
version: link:../tsconfig
'@testing-library/react':
specifier: 'catalog:'
version: 16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@types/react':
specifier: 'catalog:'
version: 19.2.18
'@types/react-dom':
specifier: 'catalog:'
version: 19.2.5(@types/react@19.2.18)
'@typescript/native':
specifier: 'catalog:'
version: typescript@7.0.2
happy-dom:
specifier: 'catalog:'
version: 20.12.0
jotai:
specifier: 'catalog:'
version: 3.0.0(@types/react@19.2.18)(react@19.2.8)
jotai-scope:
specifier: 'catalog:'
version: 0.12.2(jotai@3.0.0(@types/react@19.2.18)(react@19.2.8))(react@19.2.8)
nuqs:
specifier: 'catalog:'
version: 2.10.1(next@16.3.4(@babel/core@7.29.7(supports-color@11.0.0))(@playwright/test@1.62.1)(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)
react:
specifier: 'catalog:'
version: 19.2.8
react-dom:
specifier: 'catalog:'
version: 19.2.8(react@19.2.8)
typescript:
specifier: 'catalog:'
version: '@typescript/typescript6@6.0.2'
vite:
specifier: 'catalog:'
version: '@voidzero-dev/vite-plus-core@0.3.1(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)'
vite-plus:
specifier: 'catalog:'
version: 0.3.1(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(esbuild@0.28.2)(happy-dom@20.12.0)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)
vitest:
specifier: 'catalog:'
version: 4.1.11(@types/node@25.9.5)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(@vitest/coverage-v8@4.1.11)(@voidzero-dev/vite-plus-core@0.3.1(@types/node@25.9.5)(@typescript/typescript6@6.0.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))(happy-dom@20.12.0)
packages/tsconfig: {}
sdks/nodejs-client: