@nifrajs/web 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/query.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * The agnostic keyed query-cache — a `query(key, fn)` primitive with in-flight dedup, `staleTime`
3
- * freshness, prefix `invalidateQueries`, and bounded GC. Pure logic, no `window`, no framework, no
3
+ * freshness, prefix `invalidateQueries`, imperative cache read/write, prefetch, SSR dehydrate/hydrate,
4
+ * infinite (paged) queries, and standalone mutations. Pure logic, no `window`, no framework, no
4
5
  * `Date.now`/timers (the clock is injected) — so it unit-tests deterministically and is safe to import
5
- * from the SSR core's main entry. A per-adapter binding (`useQuery`/`createQuery`) subscribes to a
6
- * query's `subscribe`/`snapshot` store (the same shape the router + fetchers use).
6
+ * from the SSR core's main entry. A per-adapter binding (`useQuery`/`useMutation`/`createQuery`)
7
+ * subscribes to a query/mutation's `subscribe`/`snapshot` store (the same shape the router + fetchers
8
+ * use).
7
9
  *
8
10
  * This is a SECOND keyed cache, distinct from the router's path-keyed route cache (F16): route loaders
9
11
  * + navigation use the path cache; component-level interactive data (search, infinite scroll, polling)
@@ -36,6 +38,13 @@ export interface QueryHandle<T = unknown> {
36
38
  refetch: () => Promise<T>
37
39
  }
38
40
 
41
+ /** Per-query overrides passed alongside the fetcher. */
42
+ export interface QueryOptions {
43
+ /** Data stays fresh this long (ms) after a fetch; older ⇒ refetch on the next `fetch()`. Overrides
44
+ * the client default for this key. */
45
+ readonly staleTime?: number
46
+ }
47
+
39
48
  export interface QueryClientOptions {
40
49
  /** Monotonic clock in ms — injected so the core stays deterministic + testable; the adapter binding
41
50
  * passes `() => Date.now()`. */
@@ -48,14 +57,89 @@ export interface QueryClientOptions {
48
57
  readonly max?: number
49
58
  }
50
59
 
60
+ /** An infinite (paged) query's accumulated data: the fetched `pages` in order + the `pageParam` each
61
+ * was fetched with (so the next/previous param can be derived). */
62
+ export interface InfiniteData<T, P> {
63
+ readonly pages: readonly T[]
64
+ readonly pageParams: readonly P[]
65
+ }
66
+
67
+ /** Options for an {@link InfiniteQueryHandle}. `getNextPageParam` (required) derives the param for the
68
+ * next page from the last page — return `undefined`/`null` to signal there is no next page. */
69
+ export interface InfiniteQueryOptions<T, P> extends QueryOptions {
70
+ /** The param the FIRST page is fetched with. */
71
+ readonly initialPageParam: P
72
+ /** Derive the next page's param from the last fetched page (and all pages/params). `undefined`/`null`
73
+ * ⇒ no next page (`hasNextPage` is false). */
74
+ readonly getNextPageParam: (
75
+ lastPage: T,
76
+ allPages: readonly T[],
77
+ lastPageParam: P,
78
+ allPageParams: readonly P[],
79
+ ) => P | undefined | null
80
+ /** Derive the previous page's param (for bidirectional infinite lists). Omit ⇒ no previous page. */
81
+ readonly getPreviousPageParam?: (
82
+ firstPage: T,
83
+ allPages: readonly T[],
84
+ firstPageParam: P,
85
+ allPageParams: readonly P[],
86
+ ) => P | undefined | null
87
+ }
88
+
89
+ /** A stable per-key handle for an infinite (paged) query. */
90
+ export interface InfiniteQueryHandle<T, P> {
91
+ snapshot: () => QueryState<InfiniteData<T, P>>
92
+ subscribe: (listener: () => void) => () => void
93
+ /** Fetch the first page when absent/stale (fresh ⇒ no-op). */
94
+ fetch: () => Promise<InfiniteData<T, P>>
95
+ /** Refetch every currently-loaded page (in order), replacing the data. */
96
+ refetch: () => Promise<InfiniteData<T, P>>
97
+ /** Append the next page (a no-op when `hasNextPage()` is false or a fetch is already in flight). */
98
+ fetchNextPage: () => Promise<InfiniteData<T, P>>
99
+ /** Prepend the previous page (a no-op without `getPreviousPageParam` or when there is none). */
100
+ fetchPreviousPage: () => Promise<InfiniteData<T, P>>
101
+ /** Whether another page can be appended (derived from `getNextPageParam` of the last page). */
102
+ hasNextPage: () => boolean
103
+ /** Whether a page can be prepended (derived from `getPreviousPageParam` of the first page). */
104
+ hasPreviousPage: () => boolean
105
+ }
106
+
107
+ /** A serializable snapshot of the cache's successful queries — the SSR→client bridge payload. */
108
+ export interface DehydratedState {
109
+ readonly queries: ReadonlyArray<{
110
+ readonly key: unknown
111
+ readonly data: unknown
112
+ readonly updatedAt: number
113
+ }>
114
+ }
115
+
51
116
  /** The keyed query cache. One per app (a binding registers it like the router). */
52
117
  export interface QueryClient {
53
- /** Get the stable {@link QueryHandle} for `key`, fetched via `fn`. Re-binds the latest `fn` each
54
- * call (closures change between renders); returns the same handle for the same (hashed) key. */
55
- query: <T>(key: unknown, fn: () => Promise<T>) => QueryHandle<T>
118
+ /** Get the stable {@link QueryHandle} for `key`, fetched via `fn`. Re-binds the latest `fn` (and
119
+ * `options`) each call (closures change between renders); returns the same handle for the same
120
+ * (hashed) key. */
121
+ query: <T>(key: unknown, fn: () => Promise<T>, options?: QueryOptions) => QueryHandle<T>
122
+ /** Get the stable {@link InfiniteQueryHandle} for `key`, whose pages are fetched via `fn(pageParam)`. */
123
+ infiniteQuery: <T, P>(
124
+ key: unknown,
125
+ fn: (pageParam: P) => Promise<T>,
126
+ options: InfiniteQueryOptions<T, P>,
127
+ ) => InfiniteQueryHandle<T, P>
56
128
  /** Mark matching cached queries stale and refetch the **mounted** ones (subscribers > 0). Array
57
129
  * keys match by **prefix** (`["todo"]` ⇒ every `["todo", …]`); other keys match exactly. */
58
130
  invalidateQueries: (keyOrPrefix: unknown) => void
131
+ /** Read a cached query's data (`undefined` if absent or not yet successful). Synchronous, no fetch. */
132
+ getQueryData: <T>(key: unknown) => T | undefined
133
+ /** Write a query's data directly (optimistic updates, or seeding). Creates the entry if absent; marks
134
+ * it fresh (`updatedAt = now`). `updater` may be a value or a function of the previous data. */
135
+ setQueryData: <T>(key: unknown, updater: T | ((prev: T | undefined) => T)) => void
136
+ /** Fetch + cache a query's data WITHOUT subscribing (SSR prefetch, hover warm). Resolves when cached;
137
+ * a fresh entry is a no-op. Rejects if the fetch throws (caller may swallow). */
138
+ prefetchQuery: <T>(key: unknown, fn: () => Promise<T>, options?: QueryOptions) => Promise<void>
139
+ /** Serialize every successful query to a transferable snapshot (server → client SSR bridge). */
140
+ dehydrate: () => DehydratedState
141
+ /** Seed the cache from a {@link dehydrate} snapshot (client boot). Existing fresher entries win. */
142
+ hydrate: (state: DehydratedState) => void
59
143
  }
60
144
 
61
145
  /**
@@ -92,6 +176,7 @@ function keyMatchesPrefix(key: unknown, prefix: unknown): boolean {
92
176
  interface Entry {
93
177
  readonly key: unknown
94
178
  fn: () => Promise<unknown>
179
+ staleTime: number
95
180
  state: QueryState
96
181
  promise: Promise<unknown> | undefined
97
182
  /** The {@link generation} the in-flight {@link promise} started at — so a fetch begun *before* an
@@ -104,6 +189,9 @@ interface Entry {
104
189
  /** Scheduled eviction time (set when subscribers hit 0; cleared on resubscribe). */
105
190
  gcAt: number | undefined
106
191
  readonly listeners: Set<() => void>
192
+ // Entry-level operations (assigned by makeEntry; reused by query/prefetch/setQueryData/invalidate).
193
+ setState: (next: QueryState) => void
194
+ run: (force: boolean) => Promise<unknown>
107
195
  // Assigned once just after construction (the handle's closures reference this entry); not readonly.
108
196
  handle: QueryHandle
109
197
  }
@@ -116,9 +204,14 @@ const PENDING: QueryState = Object.freeze({
116
204
  updatedAt: Number.NEGATIVE_INFINITY,
117
205
  })
118
206
 
207
+ /** Build a success state for freshly-set data (imperative writes + hydration). */
208
+ function successState<T>(data: T, updatedAt: number): QueryState<T> {
209
+ return { status: "success", data, error: undefined, isFetching: false, updatedAt }
210
+ }
211
+
119
212
  export function createQueryClient(options: QueryClientOptions): QueryClient {
120
213
  const { now } = options
121
- const staleTime = options.staleTime ?? 0
214
+ const defaultStaleTime = options.staleTime ?? 0
122
215
  const gcTime = options.gcTime ?? 5 * 60_000
123
216
  const max = options.max ?? 1000
124
217
  const cache = new Map<string, Entry>()
@@ -137,17 +230,14 @@ export function createQueryClient(options: QueryClientOptions): QueryClient {
137
230
  }
138
231
  }
139
232
 
140
- const query = <T>(key: unknown, fn: () => Promise<T>): QueryHandle<T> => {
141
- sweep()
142
- const h = hashQueryKey(key)
143
- const existing = cache.get(h)
144
- if (existing !== undefined) {
145
- existing.fn = fn as () => Promise<unknown> // re-bind the latest closure
146
- return existing.handle as QueryHandle<T>
147
- }
233
+ // Build a cache entry + its handle. `fn`/`staleTime` are mutated in place on re-bind (closures change
234
+ // between renders). All the fetch/dedup/generation machinery lives here so query(), prefetchQuery(),
235
+ // setQueryData(), and invalidateQueries() share exactly one implementation.
236
+ const makeEntry = (key: unknown, fn: () => Promise<unknown>, staleTime: number): Entry => {
148
237
  const entry: Entry = {
149
238
  key,
150
- fn: fn as () => Promise<unknown>,
239
+ fn,
240
+ staleTime,
151
241
  state: PENDING,
152
242
  promise: undefined,
153
243
  promiseGen: 0,
@@ -156,24 +246,25 @@ export function createQueryClient(options: QueryClientOptions): QueryClient {
156
246
  subscribers: 0,
157
247
  gcAt: undefined,
158
248
  listeners: new Set(),
159
- // `handle` is assigned just below; the cast lets the entry reference itself.
249
+ setState: undefined as unknown as Entry["setState"],
250
+ run: undefined as unknown as Entry["run"],
160
251
  handle: undefined as unknown as QueryHandle,
161
252
  }
162
253
  const emit = (): void => {
163
- // Iterate the live set — no per-emit copy (the common case is 0–1 subscribers; the spread
164
- // allocated an array on every state transition ∝ subscriber count). Contract: a listener must
165
- // not synchronously subscribe/unsubscribe during notification — `useSyncExternalStore`/signal/
166
- // store bindings don't (un/subscribe happens in a later effect/cleanup tick).
254
+ // Iterate the live set — no per-emit copy (the common case is 0–1 subscribers). Contract: a
255
+ // listener must not synchronously subscribe/unsubscribe during notification — `useSyncExternalStore`/
256
+ // signal/store bindings don't (un/subscribe happens in a later effect/cleanup tick).
167
257
  if (entry.listeners.size === 0) return
168
258
  for (const l of entry.listeners) l()
169
259
  }
170
- const setState = (next: QueryState): void => {
260
+ entry.setState = (next: QueryState): void => {
171
261
  entry.state = Object.freeze(next)
172
262
  emit()
173
263
  }
174
- const isStale = (): boolean => entry.invalidated || now() - entry.state.updatedAt >= staleTime
264
+ const isStale = (): boolean =>
265
+ entry.invalidated || now() - entry.state.updatedAt >= entry.staleTime
175
266
 
176
- const run = (force: boolean): Promise<unknown> => {
267
+ entry.run = (force: boolean): Promise<unknown> => {
177
268
  // Dedup — but join the in-flight fetch ONLY when it belongs to the current generation. An
178
269
  // `invalidateQueries` bumps `generation`, so a fetch that started *before* the invalidation is
179
270
  // superseded and must NOT satisfy this call (its data is pre-mutation); fall through and kick a
@@ -182,7 +273,7 @@ export function createQueryClient(options: QueryClientOptions): QueryClient {
182
273
  if (!force && entry.state.status === "success" && !isStale())
183
274
  return Promise.resolve(entry.state.data) // fresh cache hit — no fn call
184
275
  const gen = entry.generation
185
- setState({ ...entry.state, isFetching: true }) // show the background/initial fetch
276
+ entry.setState({ ...entry.state, isFetching: true }) // show the background/initial fetch
186
277
  const promise = (async () => {
187
278
  try {
188
279
  const data = await entry.fn()
@@ -191,17 +282,11 @@ export function createQueryClient(options: QueryClientOptions): QueryClient {
191
282
  // that superseded this one publishes the post-mutation data.
192
283
  if (gen !== entry.generation) return data
193
284
  entry.invalidated = false
194
- setState({
195
- status: "success",
196
- data,
197
- error: undefined,
198
- isFetching: false,
199
- updatedAt: now(),
200
- })
285
+ entry.setState(successState(data, now()))
201
286
  return data
202
287
  } catch (error) {
203
288
  if (gen === entry.generation)
204
- setState({ ...entry.state, status: "error", error, isFetching: false })
289
+ entry.setState({ ...entry.state, status: "error", error, isFetching: false })
205
290
  throw error
206
291
  } finally {
207
292
  // Clear the slot only if no newer fetch took ownership. A later generation's fetch
@@ -227,11 +312,30 @@ export function createQueryClient(options: QueryClientOptions): QueryClient {
227
312
  if (entry.subscribers === 0) entry.gcAt = now() + gcTime
228
313
  }
229
314
  },
230
- fetch: () => run(false) as Promise<T>,
231
- refetch: () => run(true) as Promise<T>,
315
+ fetch: () => entry.run(false),
316
+ refetch: () => entry.run(true),
232
317
  }
318
+ return entry
319
+ }
320
+
321
+ // Get an entry by key, or create + insert a fresh one. `fn`/`staleTime` re-bind on an existing entry.
322
+ const upsert = (key: unknown, fn: () => Promise<unknown>, staleTime: number): Entry => {
323
+ const h = hashQueryKey(key)
324
+ const existing = cache.get(h)
325
+ if (existing !== undefined) {
326
+ existing.fn = fn
327
+ existing.staleTime = staleTime
328
+ return existing
329
+ }
330
+ const entry = makeEntry(key, fn, staleTime)
233
331
  cache.set(h, entry)
234
- return entry.handle as QueryHandle<T>
332
+ return entry
333
+ }
334
+
335
+ const query = <T>(key: unknown, fn: () => Promise<T>, opts?: QueryOptions): QueryHandle<T> => {
336
+ sweep()
337
+ return upsert(key, fn as () => Promise<unknown>, opts?.staleTime ?? defaultStaleTime)
338
+ .handle as QueryHandle<T>
235
339
  }
236
340
 
237
341
  const invalidateQueries = (keyOrPrefix: unknown): void => {
@@ -246,5 +350,341 @@ export function createQueryClient(options: QueryClientOptions): QueryClient {
246
350
  }
247
351
  }
248
352
 
249
- return { query, invalidateQueries }
353
+ const getQueryData = <T>(key: unknown): T | undefined => {
354
+ const entry = cache.get(hashQueryKey(key))
355
+ return entry !== undefined && entry.state.status === "success"
356
+ ? (entry.state.data as T)
357
+ : undefined
358
+ }
359
+
360
+ const setQueryData = <T>(key: unknown, updater: T | ((prev: T | undefined) => T)): void => {
361
+ // Create a placeholder entry if none exists — its `fn` throws until a real `query(key, fn)` re-binds
362
+ // it, so a `setQueryData` for a key nobody queries yet never triggers a fetch on its own.
363
+ const entry = upsert(
364
+ key,
365
+ cache.get(hashQueryKey(key))?.fn ??
366
+ (() =>
367
+ Promise.reject(new Error("[nifra/web] query has no fetcher (setQueryData-only key)"))),
368
+ defaultStaleTime,
369
+ )
370
+ const prev = entry.state.status === "success" ? (entry.state.data as T) : undefined
371
+ const data =
372
+ typeof updater === "function" ? (updater as (p: T | undefined) => T)(prev) : updater
373
+ // A direct write is the new truth — bump the generation so any in-flight fetch can't overwrite it,
374
+ // and clear the invalidated flag (the caller just supplied fresh data).
375
+ entry.generation++
376
+ entry.invalidated = false
377
+ entry.setState(successState(data, now()))
378
+ }
379
+
380
+ const prefetchQuery = async <T>(
381
+ key: unknown,
382
+ fn: () => Promise<T>,
383
+ opts?: QueryOptions,
384
+ ): Promise<void> => {
385
+ sweep()
386
+ await upsert(key, fn as () => Promise<unknown>, opts?.staleTime ?? defaultStaleTime).run(false)
387
+ }
388
+
389
+ const dehydrate = (): DehydratedState => {
390
+ const queries: Array<{ key: unknown; data: unknown; updatedAt: number }> = []
391
+ for (const entry of cache.values()) {
392
+ if (entry.state.status === "success")
393
+ queries.push({ key: entry.key, data: entry.state.data, updatedAt: entry.state.updatedAt })
394
+ }
395
+ return { queries }
396
+ }
397
+
398
+ const hydrate = (state: DehydratedState): void => {
399
+ for (const q of state.queries) {
400
+ const entry = upsert(
401
+ q.key,
402
+ cache.get(hashQueryKey(q.key))?.fn ??
403
+ (() => Promise.reject(new Error("[nifra/web] hydrated query has no fetcher yet"))),
404
+ defaultStaleTime,
405
+ )
406
+ // Don't clobber a client entry that's already fresher than the server snapshot (the client may
407
+ // have refetched between SSR and hydration). Seed only when we have no successful data or the
408
+ // incoming snapshot is newer.
409
+ if (entry.state.status !== "success" || q.updatedAt > entry.state.updatedAt)
410
+ entry.setState(successState(q.data, q.updatedAt))
411
+ }
412
+ }
413
+
414
+ // --- Infinite (paged) queries -----------------------------------------------------------------
415
+ const infiniteQuery = <T, P>(
416
+ key: unknown,
417
+ fn: (pageParam: P) => Promise<T>,
418
+ opts: InfiniteQueryOptions<T, P>,
419
+ ): InfiniteQueryHandle<T, P> => {
420
+ sweep()
421
+ const h = hashQueryKey(key)
422
+ const existing = infinite.get(h)
423
+ if (existing !== undefined) {
424
+ existing.rebind(
425
+ fn as (p: unknown) => Promise<unknown>,
426
+ opts as InfiniteQueryOptions<unknown, unknown>,
427
+ )
428
+ return existing.handle as InfiniteQueryHandle<T, P>
429
+ }
430
+ const created = makeInfinite<T, P>(fn, opts)
431
+ infinite.set(h, created as InfiniteEntry<unknown, unknown>)
432
+ return created.handle
433
+ }
434
+
435
+ // Infinite entries live in their own map (their data shape + fetch model differ from plain queries).
436
+ interface InfiniteEntry<T, P> {
437
+ handle: InfiniteQueryHandle<T, P>
438
+ rebind: (fn: (p: P) => Promise<T>, opts: InfiniteQueryOptions<T, P>) => void
439
+ }
440
+ const infinite = new Map<string, InfiniteEntry<unknown, unknown>>()
441
+
442
+ const makeInfinite = <T, P>(
443
+ fetchPage: (pageParam: P) => Promise<T>,
444
+ initialOpts: InfiniteQueryOptions<T, P>,
445
+ ): InfiniteEntry<T, P> => {
446
+ let fn = fetchPage
447
+ let opts = initialOpts
448
+ let state: QueryState<InfiniteData<T, P>> = PENDING as QueryState<InfiniteData<T, P>>
449
+ let generation = 0
450
+ let promise: Promise<InfiniteData<T, P>> | undefined
451
+ let subscribers = 0
452
+ const listeners = new Set<() => void>()
453
+ const emit = (): void => {
454
+ for (const l of listeners) l()
455
+ }
456
+ const setState = (next: QueryState<InfiniteData<T, P>>): void => {
457
+ state = Object.freeze(next)
458
+ emit()
459
+ }
460
+ const nextParam = (data: InfiniteData<T, P>): P | undefined | null => {
461
+ const last = data.pages[data.pages.length - 1]
462
+ if (last === undefined) return undefined
463
+ return opts.getNextPageParam(
464
+ last,
465
+ data.pages,
466
+ data.pageParams[data.pageParams.length - 1] as P,
467
+ data.pageParams,
468
+ )
469
+ }
470
+ const prevParam = (data: InfiniteData<T, P>): P | undefined | null => {
471
+ if (opts.getPreviousPageParam === undefined || data.pages.length === 0) return undefined
472
+ return opts.getPreviousPageParam(
473
+ data.pages[0] as T,
474
+ data.pages,
475
+ data.pageParams[0] as P,
476
+ data.pageParams,
477
+ )
478
+ }
479
+ const isStale = (): boolean => now() - state.updatedAt >= (opts.staleTime ?? defaultStaleTime)
480
+
481
+ // Run a paged operation under a fresh generation, guarding against a superseding op (like `run`).
482
+ const runPaged = (build: () => Promise<InfiniteData<T, P>>): Promise<InfiniteData<T, P>> => {
483
+ if (promise !== undefined) return promise
484
+ const gen = ++generation
485
+ setState({ ...state, isFetching: true })
486
+ const p = (async () => {
487
+ try {
488
+ const data = await build()
489
+ if (gen !== generation) return data
490
+ setState(successState(data, now()))
491
+ return data
492
+ } catch (error) {
493
+ if (gen === generation) setState({ ...state, status: "error", error, isFetching: false })
494
+ throw error
495
+ } finally {
496
+ if (gen === generation) promise = undefined
497
+ }
498
+ })()
499
+ promise = p
500
+ return p
501
+ }
502
+
503
+ const fetchFirst = (force: boolean): Promise<InfiniteData<T, P>> => {
504
+ if (!force && state.status === "success" && !isStale() && state.data !== undefined)
505
+ return Promise.resolve(state.data)
506
+ return runPaged(async () => {
507
+ const page = await fn(opts.initialPageParam)
508
+ return { pages: [page], pageParams: [opts.initialPageParam] }
509
+ })
510
+ }
511
+
512
+ const handle: InfiniteQueryHandle<T, P> = {
513
+ snapshot: () => state,
514
+ subscribe: (listener) => {
515
+ subscribers++
516
+ listeners.add(listener)
517
+ return () => {
518
+ listeners.delete(listener)
519
+ subscribers--
520
+ }
521
+ },
522
+ fetch: () => fetchFirst(false),
523
+ refetch: () =>
524
+ runPaged(async () => {
525
+ const current = state.data
526
+ if (current === undefined || current.pages.length === 0) {
527
+ const page = await fn(opts.initialPageParam)
528
+ return { pages: [page], pageParams: [opts.initialPageParam] }
529
+ }
530
+ // Refetch every loaded page in order with its original param, so the list stays the same size.
531
+ const pages: T[] = []
532
+ for (const param of current.pageParams) pages.push(await fn(param))
533
+ return { pages, pageParams: [...current.pageParams] }
534
+ }),
535
+ fetchNextPage: () => {
536
+ const current = state.data
537
+ if (current === undefined) return fetchFirst(false)
538
+ const param = nextParam(current)
539
+ if (param === undefined || param === null) return Promise.resolve(current)
540
+ return runPaged(async () => {
541
+ const page = await fn(param)
542
+ return { pages: [...current.pages, page], pageParams: [...current.pageParams, param] }
543
+ })
544
+ },
545
+ fetchPreviousPage: () => {
546
+ const current = state.data
547
+ if (current === undefined) return fetchFirst(false)
548
+ const param = prevParam(current)
549
+ if (param === undefined || param === null) return Promise.resolve(current)
550
+ return runPaged(async () => {
551
+ const page = await fn(param)
552
+ return { pages: [page, ...current.pages], pageParams: [param, ...current.pageParams] }
553
+ })
554
+ },
555
+ hasNextPage: () => {
556
+ const p = state.data === undefined ? undefined : nextParam(state.data)
557
+ return p !== undefined && p !== null
558
+ },
559
+ hasPreviousPage: () => {
560
+ const p = state.data === undefined ? undefined : prevParam(state.data)
561
+ return p !== undefined && p !== null
562
+ },
563
+ }
564
+ return {
565
+ handle: handle as InfiniteQueryHandle<unknown, unknown>,
566
+ rebind: (nextFn, nextOpts) => {
567
+ fn = nextFn
568
+ opts = nextOpts
569
+ },
570
+ } as InfiniteEntry<T, P>
571
+ }
572
+
573
+ return {
574
+ query,
575
+ infiniteQuery,
576
+ invalidateQueries,
577
+ getQueryData,
578
+ setQueryData,
579
+ prefetchQuery,
580
+ dehydrate,
581
+ hydrate,
582
+ }
583
+ }
584
+
585
+ // --- Standalone mutations -----------------------------------------------------------------------
586
+
587
+ /** A mutation's lifecycle status. */
588
+ export type MutationStatus = "idle" | "pending" | "success" | "error"
589
+
590
+ /** A mutation's observable state. A new (frozen) object per transition (reference-comparable). */
591
+ export interface MutationState<TData, TVariables> {
592
+ readonly status: MutationStatus
593
+ readonly data: TData | undefined
594
+ readonly error: unknown
595
+ /** The variables of the in-flight / last mutation (`undefined` before the first call). */
596
+ readonly variables: TVariables | undefined
597
+ }
598
+
599
+ /** Lifecycle callbacks for a mutation. All optional; `onSettled` runs after success OR error. */
600
+ export interface MutationCallbacks<TData, TVariables> {
601
+ readonly onMutate?: (variables: TVariables) => void | Promise<void>
602
+ readonly onSuccess?: (data: TData, variables: TVariables) => void | Promise<void>
603
+ readonly onError?: (error: unknown, variables: TVariables) => void | Promise<void>
604
+ readonly onSettled?: (
605
+ data: TData | undefined,
606
+ error: unknown,
607
+ variables: TVariables,
608
+ ) => void | Promise<void>
609
+ }
610
+
611
+ /** A standalone mutation store: subscribe to its state, fire `mutate`, `reset` back to idle. */
612
+ export interface MutationHandle<TData, TVariables> {
613
+ snapshot: () => MutationState<TData, TVariables>
614
+ subscribe: (listener: () => void) => () => void
615
+ /** Run the mutation. Resolves to the data (and runs onSuccess/onSettled) or rejects (onError/onSettled).
616
+ * The latest concurrent call wins the published state (older results are dropped). */
617
+ mutate: (variables: TVariables) => Promise<TData>
618
+ /** Rebind the latest `fn`/callbacks (closures change between renders) without losing state. */
619
+ rebind: (
620
+ fn: (variables: TVariables) => Promise<TData>,
621
+ callbacks: MutationCallbacks<TData, TVariables>,
622
+ ) => void
623
+ /** Reset to the idle state (clears data/error/variables). */
624
+ reset: () => void
625
+ }
626
+
627
+ const IDLE_MUTATION: MutationState<unknown, unknown> = Object.freeze({
628
+ status: "idle",
629
+ data: undefined,
630
+ error: undefined,
631
+ variables: undefined,
632
+ })
633
+
634
+ /**
635
+ * Create a standalone mutation state machine — framework-agnostic, so a per-adapter `useMutation`
636
+ * binding just subscribes to it. Single-flight by a monotonic token: overlapping `mutate` calls each
637
+ * run their `fn`, but only the latest publishes state (an older, slower response can't clobber a newer
638
+ * one). The callbacks fire in TanStack order: `onMutate` (before), then `onSuccess`/`onError`, then
639
+ * `onSettled`.
640
+ */
641
+ export function createMutation<TData, TVariables>(
642
+ fn: (variables: TVariables) => Promise<TData>,
643
+ callbacks: MutationCallbacks<TData, TVariables> = {},
644
+ ): MutationHandle<TData, TVariables> {
645
+ let mutationFn = fn
646
+ let cbs = callbacks
647
+ let state = IDLE_MUTATION as MutationState<TData, TVariables>
648
+ let token = 0
649
+ const listeners = new Set<() => void>()
650
+ const emit = (): void => {
651
+ for (const l of listeners) l()
652
+ }
653
+ const setState = (next: MutationState<TData, TVariables>): void => {
654
+ state = Object.freeze(next)
655
+ emit()
656
+ }
657
+ return {
658
+ snapshot: () => state,
659
+ subscribe: (listener) => {
660
+ listeners.add(listener)
661
+ return () => {
662
+ listeners.delete(listener)
663
+ }
664
+ },
665
+ rebind: (nextFn, nextCallbacks) => {
666
+ mutationFn = nextFn
667
+ cbs = nextCallbacks
668
+ },
669
+ reset: () => setState(IDLE_MUTATION as MutationState<TData, TVariables>),
670
+ mutate: async (variables) => {
671
+ const mine = ++token
672
+ setState({ status: "pending", data: undefined, error: undefined, variables })
673
+ // Only await a callback when one is actually supplied — an `await undefined` would insert a
674
+ // needless microtask before the mutationFn runs (and complicates single-flight timing).
675
+ if (cbs.onMutate !== undefined) await cbs.onMutate(variables)
676
+ try {
677
+ const data = await mutationFn(variables)
678
+ if (mine === token) setState({ status: "success", data, error: undefined, variables })
679
+ if (cbs.onSuccess !== undefined) await cbs.onSuccess(data, variables)
680
+ if (cbs.onSettled !== undefined) await cbs.onSettled(data, undefined, variables)
681
+ return data
682
+ } catch (error) {
683
+ if (mine === token) setState({ status: "error", data: undefined, error, variables })
684
+ if (cbs.onError !== undefined) await cbs.onError(error, variables)
685
+ if (cbs.onSettled !== undefined) await cbs.onSettled(undefined, error, variables)
686
+ throw error
687
+ }
688
+ },
689
+ }
250
690
  }