@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/dist/client.d.ts.map +1 -1
- package/dist/client.js +24 -3
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +18 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +40 -6
- package/dist/index.js.map +1 -1
- package/dist/navigation.d.ts +31 -0
- package/dist/navigation.d.ts.map +1 -0
- package/dist/navigation.js +27 -0
- package/dist/navigation.js.map +1 -0
- package/dist/query.d.ts +108 -6
- package/dist/query.d.ts.map +1 -1
- package/dist/query.js +309 -34
- package/dist/query.js.map +1 -1
- package/package.json +2 -2
- package/src/client.ts +24 -3
- package/src/index.ts +70 -5
- package/src/navigation.ts +42 -0
- package/src/query.ts +477 -37
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`,
|
|
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`)
|
|
6
|
-
* query's `subscribe`/`snapshot` store (the same shape the router + fetchers
|
|
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`
|
|
54
|
-
* call (closures change between renders); returns the same handle for the same
|
|
55
|
-
|
|
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
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
164
|
-
//
|
|
165
|
-
//
|
|
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
|
-
|
|
260
|
+
entry.setState = (next: QueryState): void => {
|
|
171
261
|
entry.state = Object.freeze(next)
|
|
172
262
|
emit()
|
|
173
263
|
}
|
|
174
|
-
const isStale = (): boolean =>
|
|
264
|
+
const isStale = (): boolean =>
|
|
265
|
+
entry.invalidated || now() - entry.state.updatedAt >= entry.staleTime
|
|
175
266
|
|
|
176
|
-
|
|
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)
|
|
231
|
-
refetch: () => run(true)
|
|
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
|
|
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
|
-
|
|
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
|
}
|