mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
feat(nuqs-jotai): bridge nuqs URL state into Jotai (#42086)
This commit is contained in:
1 parent
608b1d10e0
commit
7f59b65b6a
14 files changed
+1529
No files matched your search
@@ -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.
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
@@ -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>()
|
||||
})
|
||||
@@ -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])
|
||||
})
|
||||
@@ -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([''])
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite-plus'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
},
|
||||
})
|
||||
Generated
+48
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user