what-core 0.12.2 → 0.12.4
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/{chunk-NCPX66TV.min.js → chunk-M5GDJRVX.min.js} +1 -1
- package/dist/chunk-T2SKNKT5.min.js +11 -0
- package/dist/index.min.js +5 -5
- package/dist/render.min.js +1 -1
- package/dist/testing.min.js +1 -1
- package/index.d.ts +329 -40
- package/package.json +1 -1
- package/src/a11y.js +234 -23
- package/src/agent-context.js +1 -1
- package/src/animation.js +8 -0
- package/src/data.js +730 -105
- package/src/dom.js +52 -0
- package/src/errors.js +12 -1
- package/src/form.js +329 -31
- package/src/hooks.js +20 -3
- package/src/index.js +6 -0
- package/src/render.js +872 -50
- package/src/scheduler.js +17 -0
- package/src/warnings.js +83 -0
- package/dist/chunk-RXISSKLI.min.js +0 -11
package/src/data.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// What Framework - Data Fetching
|
|
2
2
|
// SWR-like data fetching with caching, revalidation, and optimistic updates
|
|
3
3
|
|
|
4
|
-
import { signal, effect, batch, computed, __DEV__ } from './reactive.js';
|
|
4
|
+
import { signal, effect, batch, computed, untrack, __DEV__ } from './reactive.js';
|
|
5
5
|
import { getCurrentComponent } from './dom.js';
|
|
6
6
|
|
|
7
7
|
// --- Reactive Cache ---
|
|
@@ -87,9 +87,78 @@ function subscribeToKey(key, revalidateFn) {
|
|
|
87
87
|
};
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
|
|
90
|
+
// What clearCache() cannot reach by walking the cache Maps.
|
|
91
|
+
//
|
|
92
|
+
// Two different things live outside them. useInfiniteQuery keeps its pages in
|
|
93
|
+
// signals local to the hook (see the note above it on why), so walking
|
|
94
|
+
// cacheSignals emptied nothing at all for it. And EVERY hook keeps the request
|
|
95
|
+
// it currently has in flight in a local AbortController: emptying the cache
|
|
96
|
+
// does not cancel a request that is already on the wire, so the previous user's
|
|
97
|
+
// response lands a moment after the clear and writes their data straight back
|
|
98
|
+
// onto the screen. That is the exact failure clearCache exists to prevent, at
|
|
99
|
+
// the exact moment (logout) it is called for, and it was guarded in
|
|
100
|
+
// useInfiniteQuery and nowhere else.
|
|
101
|
+
//
|
|
102
|
+
// A handler is registered for the lifetime of the hook's effect, the same
|
|
103
|
+
// lifetime as its invalidation subscription, so it goes away on unmount with
|
|
104
|
+
// everything else.
|
|
105
|
+
const clearCacheHandlers = new Set();
|
|
106
|
+
|
|
107
|
+
function registerClearCacheHandler(handler) {
|
|
108
|
+
clearCacheHandlers.add(handler);
|
|
109
|
+
return () => clearCacheHandlers.delete(handler);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const inFlightRequests = new Map(); // key -> { promise, timestamp, refCount, epoch }
|
|
91
113
|
const lastFetchTimestamps = new Map(); // key -> timestamp of last completed fetch
|
|
92
114
|
|
|
115
|
+
// How many times each key has been invalidated. This orders invalidations
|
|
116
|
+
// against in-flight requests, which a wall clock cannot: Date.now() has 1ms
|
|
117
|
+
// resolution, so a refetch and an unrelated invalidation issued microseconds
|
|
118
|
+
// apart share a timestamp, and the invalidation would be answered by a request
|
|
119
|
+
// that predates the mutation. A counter has no ties.
|
|
120
|
+
const keyEpochs = new Map();
|
|
121
|
+
|
|
122
|
+
function currentEpoch(key) {
|
|
123
|
+
return keyEpochs.get(key) || 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function bumpEpoch(key) {
|
|
127
|
+
keyEpochs.set(key, currentEpoch(key) + 1);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Every timer this module arms is housekeeping or polling. None of it is work
|
|
131
|
+
// worth keeping a Node process alive for, and on the server the usual owner of a
|
|
132
|
+
// timer (a component that unmounts) does not exist, so nothing ever clears them:
|
|
133
|
+
// an SSR render that touched one query pinned the event loop for minutes and kept
|
|
134
|
+
// firing the query function long after the HTML had been sent.
|
|
135
|
+
function unrefTimer(timer) {
|
|
136
|
+
if (timer && typeof timer.unref === 'function') timer.unref();
|
|
137
|
+
return timer;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// EVERY computed() in this file reads EVERY signal it depends on
|
|
141
|
+
// UNCONDITIONALLY, even when a short circuit would let it skip one.
|
|
142
|
+
//
|
|
143
|
+
// This is not a style preference, it is a requirement of the reactive core. A
|
|
144
|
+
// computed is backed by an effect, and _runEffect auto-promotes an effect to
|
|
145
|
+
// "stable" when it had exactly one dependency before a re-run and the same
|
|
146
|
+
// single dependency after it (the assumption being that a one-dependency effect
|
|
147
|
+
// cannot have conditional reads). A stable effect re-runs with currentEffect
|
|
148
|
+
// set to null: it still produces a fresh value, but it can never SUBSCRIBE to
|
|
149
|
+
// anything it was not already subscribed to.
|
|
150
|
+
//
|
|
151
|
+
// So a computed of the shape `a() === 'x' && b()` is one re-run away from
|
|
152
|
+
// permanent deafness to `b`: any re-run in which `a` is not 'x' reads only `a`,
|
|
153
|
+
// promotes the computed, and from then on `b` can change without the computed
|
|
154
|
+
// ever being notified. It goes stale silently and forever, and only for some
|
|
155
|
+
// orderings of the writes, which is why both instances of this in the file
|
|
156
|
+
// looked fine in tests and failed in an app. See the notes on useQuery's
|
|
157
|
+
// `status` and useSWR's `isLoading` for what each one actually broke.
|
|
158
|
+
//
|
|
159
|
+
// Reading everything unconditionally fixes it whatever the promotion rule is:
|
|
160
|
+
// the dependency set never varies, so there is nothing left to lose.
|
|
161
|
+
|
|
93
162
|
// Create an effect scoped to the current component's lifecycle.
|
|
94
163
|
// When the component unmounts, the effect is automatically disposed.
|
|
95
164
|
function scopedEffect(fn) {
|
|
@@ -99,6 +168,49 @@ function scopedEffect(fn) {
|
|
|
99
168
|
return dispose;
|
|
100
169
|
}
|
|
101
170
|
|
|
171
|
+
// Register a teardown with the component that owns this hook.
|
|
172
|
+
//
|
|
173
|
+
// scopedEffect's cleanup is not the right home for all of it: that cleanup runs
|
|
174
|
+
// before every RE-RUN of the effect as well as on disposal, and some teardown
|
|
175
|
+
// (cancelling a request the application explicitly asked for) must happen only
|
|
176
|
+
// when the component actually goes away. Outside a component there is nothing
|
|
177
|
+
// to unmount and the effect is never disposed either, so the two agree.
|
|
178
|
+
function onComponentDispose(fn) {
|
|
179
|
+
const ctx = getCurrentComponent?.();
|
|
180
|
+
if (ctx) ctx.effects.push(fn);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// `enabled` as a READABLE gate rather than a value captured once.
|
|
184
|
+
//
|
|
185
|
+
// Components run once here, so a plain boolean read at call time is frozen for
|
|
186
|
+
// the lifetime of the query: `enabled: userId() != null` could never become
|
|
187
|
+
// true, and the whole point of the option (a dependent query that starts when
|
|
188
|
+
// its dependency arrives) was unreachable. Accepting a signal or any thunk lets
|
|
189
|
+
// the query's own effect track it, so flipping the flag starts the query.
|
|
190
|
+
//
|
|
191
|
+
// The thunk is MIRRORED into a boolean signal rather than read by the query's
|
|
192
|
+
// own effect. Reading it there subscribes that effect to everything the thunk
|
|
193
|
+
// touches, and re-running it is destructive: it aborts whatever request is in
|
|
194
|
+
// flight. So `enabled: () => tab() === 'reports'` let an unrelated move of
|
|
195
|
+
// `tab` cancel the query even though the gate's value never changed, which
|
|
196
|
+
// silently killed an explicit refetch() (its promise resolved with `undefined`:
|
|
197
|
+
// no data, no error, no rejection), and `enabled: () => userId() != null`
|
|
198
|
+
// issued a redundant second fetch on every change of `userId`, into the
|
|
199
|
+
// queryKey captured at hook creation, i.e. the OLD key. A signal does not
|
|
200
|
+
// notify when it is written the value it already holds, so mirroring collapses
|
|
201
|
+
// "something the thunk reads moved" down to "the gate flipped", which is the
|
|
202
|
+
// only event a query has any business reacting to.
|
|
203
|
+
//
|
|
204
|
+
// A boolean is still accepted and still behaves exactly as before: it reads no
|
|
205
|
+
// signals, so the mirroring effect tracks no dependencies and the reactive core
|
|
206
|
+
// releases it on the spot.
|
|
207
|
+
function createGate(enabled) {
|
|
208
|
+
const read = typeof enabled === 'function' ? enabled : () => enabled;
|
|
209
|
+
const gate = signal(untrack(() => !!read()));
|
|
210
|
+
scopedEffect(() => { gate.set(!!read()); });
|
|
211
|
+
return gate;
|
|
212
|
+
}
|
|
213
|
+
|
|
102
214
|
// --- useFetch Hook ---
|
|
103
215
|
// Simple fetch with automatic JSON parsing and error handling
|
|
104
216
|
|
|
@@ -175,7 +287,7 @@ export function useFetch(url, options = {}) {
|
|
|
175
287
|
// --- useSWR Hook ---
|
|
176
288
|
// Stale-while-revalidate pattern with caching
|
|
177
289
|
|
|
178
|
-
export function useSWR(
|
|
290
|
+
export function useSWR(rawKey, fetcher, options = {}) {
|
|
179
291
|
const {
|
|
180
292
|
revalidateOnFocus = true,
|
|
181
293
|
revalidateOnReconnect = true,
|
|
@@ -189,7 +301,7 @@ export function useSWR(key, fetcher, options = {}) {
|
|
|
189
301
|
|
|
190
302
|
// Support null/undefined/false key for conditional/dependent fetching
|
|
191
303
|
// When key is falsy, don't fetch — return idle state
|
|
192
|
-
if (
|
|
304
|
+
if (rawKey == null || rawKey === false) {
|
|
193
305
|
const data = signal(fallbackData || null);
|
|
194
306
|
const error = signal(null);
|
|
195
307
|
return {
|
|
@@ -202,23 +314,72 @@ export function useSWR(key, fetcher, options = {}) {
|
|
|
202
314
|
};
|
|
203
315
|
}
|
|
204
316
|
|
|
317
|
+
// Normalized into the same flat string space every other cache-facing entry
|
|
318
|
+
// point uses (see normalizeQueryKey, which says every one of them must
|
|
319
|
+
// normalize identically). useSWR was the one that did not, so an ARRAY key
|
|
320
|
+
// was used as a Map key by object IDENTITY, and the documented array-key
|
|
321
|
+
// shape `useSWR(['/api/user', id], ...)` broke three ways at once: two
|
|
322
|
+
// components passing equal-but-distinct arrays got two cache entries and
|
|
323
|
+
// never saw each other's data, getQueryData(['/api/user', 1]) could not find
|
|
324
|
+
// what was there, and an Array reached invalidateQueries' predicate, where
|
|
325
|
+
// the documented `key => key.startsWith('/api/posts')` throws -- before any
|
|
326
|
+
// key has been bumped, so ONE array key anywhere in the app turned every
|
|
327
|
+
// predicate invalidation into a no-op that reported itself as a TypeError.
|
|
328
|
+
//
|
|
329
|
+
// The FETCHER still receives the original key. SWR's contract is
|
|
330
|
+
// `useSWR(['/api/user', id], ([url, id]) => ...)`, and how the cache spells a
|
|
331
|
+
// key internally is none of the fetcher's business.
|
|
332
|
+
const key = normalizeQueryKey(rawKey);
|
|
333
|
+
|
|
205
334
|
// Shared reactive cache signals — all useSWR instances with the same key
|
|
206
335
|
// read from these signals, so mutating from one component updates all others.
|
|
207
336
|
const cacheS = getCacheSignal(key);
|
|
208
337
|
const error = getErrorSignal(key);
|
|
209
338
|
const isValidating = getValidatingSignal(key);
|
|
210
339
|
const data = computed(() => cacheS() ?? fallbackData ?? null);
|
|
211
|
-
|
|
340
|
+
// Both reads are unconditional. See the note on conditional reads above
|
|
341
|
+
// scopedEffect: as `cacheS() == null && isValidating()` this computed dropped
|
|
342
|
+
// to a single dependency on any re-run that found the cache full (a mutate(),
|
|
343
|
+
// a setQueryData(), a second successful fetch), was promoted to a stable
|
|
344
|
+
// effect there, and never tracked isValidating again -- after which
|
|
345
|
+
// isLoading() answered false for the rest of the page's life, including with
|
|
346
|
+
// an empty cache and a request in flight, which is the one state it exists to
|
|
347
|
+
// report.
|
|
348
|
+
const isLoading = computed(() => {
|
|
349
|
+
const empty = cacheS() == null;
|
|
350
|
+
const fetching = isValidating();
|
|
351
|
+
return empty && fetching;
|
|
352
|
+
});
|
|
212
353
|
|
|
213
354
|
let abortController = null;
|
|
214
355
|
|
|
215
|
-
|
|
356
|
+
// `force` is invalidateQueries(): "this data is wrong now". It bypasses the
|
|
357
|
+
// FRESHNESS window, which is the one caller that must never be answered from
|
|
358
|
+
// it (with the default 2s dedupingInterval an invalidation issued right after
|
|
359
|
+
// a fetch was silently swallowed and the stale data stayed on screen).
|
|
360
|
+
//
|
|
361
|
+
// It does NOT bypass request COALESCING, which is a different mechanism
|
|
362
|
+
// wearing a similar name. Every component reading a key subscribes
|
|
363
|
+
// separately, so one invalidateQueries() call fans out to N subscribers; the
|
|
364
|
+
// in-flight map is what collapses those back into one request. Skipping it
|
|
365
|
+
// opened N concurrent fetches of the same key whose responses then raced to
|
|
366
|
+
// write the cache.
|
|
367
|
+
//
|
|
368
|
+
// What force does change is WHICH in-flight request is acceptable. A response
|
|
369
|
+
// to a request that started before the data was invalidated is already stale,
|
|
370
|
+
// so it cannot answer the invalidation. One that started after it is a
|
|
371
|
+
// sibling subscriber's, and is exactly what we want to join.
|
|
372
|
+
async function revalidate({ force = false } = {}) {
|
|
216
373
|
const now = Date.now();
|
|
374
|
+
const epoch = currentEpoch(key);
|
|
217
375
|
|
|
218
376
|
// Deduplication: if there's already a request in flight, reuse it
|
|
219
377
|
if (inFlightRequests.has(key)) {
|
|
220
378
|
const existing = inFlightRequests.get(key);
|
|
221
|
-
|
|
379
|
+
const usable = force
|
|
380
|
+
? existing.epoch === epoch
|
|
381
|
+
: now - existing.timestamp < dedupingInterval;
|
|
382
|
+
if (usable) {
|
|
222
383
|
existing.refCount++;
|
|
223
384
|
return existing.promise;
|
|
224
385
|
}
|
|
@@ -226,7 +387,7 @@ export function useSWR(key, fetcher, options = {}) {
|
|
|
226
387
|
|
|
227
388
|
// Also deduplicate against recently completed fetches
|
|
228
389
|
const lastFetch = lastFetchTimestamps.get(key);
|
|
229
|
-
if (lastFetch && now - lastFetch < dedupingInterval && cacheS.peek() != null) {
|
|
390
|
+
if (!force && lastFetch && now - lastFetch < dedupingInterval && cacheS.peek() != null) {
|
|
230
391
|
return cacheS.peek();
|
|
231
392
|
}
|
|
232
393
|
|
|
@@ -242,8 +403,9 @@ export function useSWR(key, fetcher, options = {}) {
|
|
|
242
403
|
|
|
243
404
|
isValidating.set(true);
|
|
244
405
|
|
|
245
|
-
|
|
246
|
-
|
|
406
|
+
// rawKey, not the normalized one: see the note where `key` is derived.
|
|
407
|
+
const promise = fetcher(rawKey, { signal: abortSignal });
|
|
408
|
+
inFlightRequests.set(key, { promise, timestamp: now, refCount: 1, epoch });
|
|
247
409
|
|
|
248
410
|
try {
|
|
249
411
|
const result = await promise;
|
|
@@ -254,12 +416,12 @@ export function useSWR(key, fetcher, options = {}) {
|
|
|
254
416
|
});
|
|
255
417
|
cacheTimestamps.set(key, Date.now());
|
|
256
418
|
lastFetchTimestamps.set(key, Date.now());
|
|
257
|
-
if (onSuccess) onSuccess(result,
|
|
419
|
+
if (onSuccess) onSuccess(result, rawKey);
|
|
258
420
|
return result;
|
|
259
421
|
} catch (e) {
|
|
260
422
|
if (abortSignal.aborted) return;
|
|
261
423
|
error.set(e);
|
|
262
|
-
if (onError) onError(e,
|
|
424
|
+
if (onError) onError(e, rawKey);
|
|
263
425
|
throw e;
|
|
264
426
|
} finally {
|
|
265
427
|
if (!abortSignal.aborted) isValidating.set(false);
|
|
@@ -271,16 +433,34 @@ export function useSWR(key, fetcher, options = {}) {
|
|
|
271
433
|
}
|
|
272
434
|
}
|
|
273
435
|
|
|
274
|
-
//
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
//
|
|
436
|
+
// Initial fetch, plus the invalidation subscription for this key.
|
|
437
|
+
//
|
|
438
|
+
// The subscription lives INSIDE the effect. It used to be created once
|
|
439
|
+
// outside, while the effect's cleanup tore it down, and this effect re-runs
|
|
440
|
+
// whenever the fetcher reads a signal that changed. So the first reactive
|
|
441
|
+
// refetch unsubscribed the key and never resubscribed: from then on
|
|
442
|
+
// invalidateQueries() had no subscriber to call and silently did nothing.
|
|
443
|
+
// Re-subscribing per run keeps the two halves in the same lifecycle.
|
|
278
444
|
scopedEffect(() => {
|
|
445
|
+
const unsubscribe = subscribeToKey(key, () => revalidate({ force: true }).catch(() => {}));
|
|
446
|
+
// clearCache() empties this key's shared signals, but the request already on
|
|
447
|
+
// the wire is not in any Map it can walk: it landed after the clear and put
|
|
448
|
+
// the previous user's data back. See registerClearCacheHandler.
|
|
449
|
+
const releaseHandler = registerClearCacheHandler(() => {
|
|
450
|
+
if (abortController) {
|
|
451
|
+
abortController.abort();
|
|
452
|
+
abortController = null;
|
|
453
|
+
}
|
|
454
|
+
// isValidating is the SHARED signal for this key and clearCache has
|
|
455
|
+
// already set it false; the aborted request deliberately returns without
|
|
456
|
+
// touching it, so there is nothing left to reset here.
|
|
457
|
+
});
|
|
279
458
|
revalidate().catch(() => {});
|
|
280
459
|
// Cleanup: abort and unsubscribe on unmount
|
|
281
460
|
return () => {
|
|
282
461
|
if (abortController) abortController.abort();
|
|
283
462
|
unsubscribe();
|
|
463
|
+
releaseHandler();
|
|
284
464
|
};
|
|
285
465
|
});
|
|
286
466
|
|
|
@@ -309,9 +489,9 @@ export function useSWR(key, fetcher, options = {}) {
|
|
|
309
489
|
// Polling
|
|
310
490
|
if (refreshInterval > 0) {
|
|
311
491
|
scopedEffect(() => {
|
|
312
|
-
const interval = setInterval(() => {
|
|
492
|
+
const interval = unrefTimer(setInterval(() => {
|
|
313
493
|
revalidate().catch(() => {});
|
|
314
|
-
}, refreshInterval);
|
|
494
|
+
}, refreshInterval));
|
|
315
495
|
return () => clearInterval(interval);
|
|
316
496
|
});
|
|
317
497
|
}
|
|
@@ -355,36 +535,120 @@ export function useQuery(options) {
|
|
|
355
535
|
} = options;
|
|
356
536
|
|
|
357
537
|
const key = normalizeQueryKey(queryKey);
|
|
538
|
+
const gate = createGate(enabled);
|
|
358
539
|
|
|
359
540
|
const cacheS = getCacheSignal(key);
|
|
360
541
|
const data = computed(() => {
|
|
361
542
|
const d = cacheS();
|
|
362
|
-
|
|
543
|
+
// `!= null`, not `!== null`: an emptied entry reads `undefined` (see
|
|
544
|
+
// clearCache), and handing a user's select() an undefined it was never
|
|
545
|
+
// written to expect turns a cleared cache into a crash inside their code.
|
|
546
|
+
return select && d != null ? select(d) : d;
|
|
363
547
|
});
|
|
364
548
|
const error = getErrorSignal(key);
|
|
365
|
-
|
|
549
|
+
// What this hook WRITES. Components read the derived `status` just below.
|
|
550
|
+
//
|
|
551
|
+
// A disabled query with nothing cached is NOT loading: no request is in
|
|
552
|
+
// flight and none will be until it is enabled or refetch() is called.
|
|
553
|
+
// Reporting 'loading' forever made "waiting for the user" indistinguishable
|
|
554
|
+
// from "waiting for the network", so a spinner rendered on isLoading() never
|
|
555
|
+
// came down. 'idle' is the honest third state, and isLoading() is false in it.
|
|
556
|
+
const rawStatus = signal(
|
|
557
|
+
cacheS.peek() != null ? 'success' : (gate.peek() ? 'loading' : 'idle')
|
|
558
|
+
);
|
|
366
559
|
const fetchStatus = signal('idle');
|
|
367
560
|
|
|
368
|
-
|
|
369
|
-
|
|
561
|
+
// A settled status only holds while the cache still holds what it settled on.
|
|
562
|
+
// The data lives in a SHARED signal that something this hook never hears
|
|
563
|
+
// about can empty -- clearCache() on logout, a sibling's
|
|
564
|
+
// setQueryData(key, null) -- and a per-hook status signal has no way to
|
|
565
|
+
// notice. That left status 'success' with data() === undefined, which walks
|
|
566
|
+
// the canonical guarded render
|
|
567
|
+
//
|
|
568
|
+
// if (q.isLoading()) return 'Loading...';
|
|
569
|
+
// if (q.isError()) return 'Error';
|
|
570
|
+
// if (q.isIdle()) return 'Idle';
|
|
571
|
+
// return q.data().name;
|
|
572
|
+
//
|
|
573
|
+
// past every guard and into a TypeError, at the one moment (logout) when
|
|
574
|
+
// clearCache is most likely to be called. Deriving keeps status and data
|
|
575
|
+
// moving together whatever emptied the entry, instead of requiring every
|
|
576
|
+
// writer of the shared cache to know about every hook reading it.
|
|
577
|
+
//
|
|
578
|
+
// All four reads are UNCONDITIONAL, and that is load-bearing rather than
|
|
579
|
+
// tidy. Written as `s === 'success' && cacheS() == null` this computed reads
|
|
580
|
+
// the cache only in the success branch, so a re-run that finds any other
|
|
581
|
+
// status reads rawStatus alone -- one dependency -- and the reactive core
|
|
582
|
+
// promotes it to a stable effect that can never subscribe to anything new.
|
|
583
|
+
// See the note on conditional reads above scopedEffect.
|
|
584
|
+
//
|
|
585
|
+
// A query created with `enabled: false` takes exactly that path and an
|
|
586
|
+
// enabled one does not, which is why this looked fixed: 'idle' -> 'loading'
|
|
587
|
+
// (refetch starts) is a one-dependency re-run, where an enabled query goes
|
|
588
|
+
// straight from 'loading' to 'success' in a batch that writes the cache too,
|
|
589
|
+
// and so keeps both dependencies. So after refetch() a disabled query's
|
|
590
|
+
// status was permanently deaf to its own cache, and clearCache() left it
|
|
591
|
+
// reporting 'success' with data() === undefined -- walking the guarded render
|
|
592
|
+
// above into the TypeError this computed was written to prevent, at logout,
|
|
593
|
+
// with the previous user's value still painted on screen.
|
|
594
|
+
const status = computed(() => {
|
|
595
|
+
const s = rawStatus();
|
|
596
|
+
const hasData = cacheS() != null;
|
|
597
|
+
const hasError = error() != null;
|
|
598
|
+
// 'loading' only when something really is on its way to refill the entry.
|
|
599
|
+
// clearCache() starts no request, so reporting 'loading' there would render
|
|
600
|
+
// a spinner that never comes down -- the same defect, moved.
|
|
601
|
+
const refilling = fetchStatus() === 'fetching';
|
|
602
|
+
const lostData = s === 'success' && !hasData;
|
|
603
|
+
const lostError = s === 'error' && !hasError;
|
|
604
|
+
if (lostData || lostError) return refilling ? 'loading' : 'idle';
|
|
605
|
+
return s;
|
|
606
|
+
});
|
|
370
607
|
|
|
371
|
-
|
|
372
|
-
|
|
608
|
+
let lastFetchTime = 0;
|
|
609
|
+
// The request this hook has in flight, and who asked for it. An AUTOMATIC
|
|
610
|
+
// fetch (mount, focus, polling, invalidation) belongs to the effect below and
|
|
611
|
+
// dies with it; a `manual` one was asked for by application code, and the
|
|
612
|
+
// effect's cleanup must leave it alone. One shared controller meant an
|
|
613
|
+
// unrelated re-render cancelled a button click's refetch(), whose promise
|
|
614
|
+
// then resolved with `undefined`.
|
|
615
|
+
let inFlight = null;
|
|
616
|
+
let cleanupTimer = null;
|
|
617
|
+
|
|
618
|
+
// See the note on useSWR's revalidate: an invalidation must not be answered
|
|
619
|
+
// from the freshness window, or `invalidateQueries` becomes a no-op for every
|
|
620
|
+
// query with a staleTime.
|
|
621
|
+
//
|
|
622
|
+
// `manual` separates an explicit refetch() from AUTOMATIC fetching (mount,
|
|
623
|
+
// window focus, polling, invalidation). Only the automatic paths are gated by
|
|
624
|
+
// `enabled`: a call from application code is a direct request for data, and
|
|
625
|
+
// gating it left `enabled: false` + "fetch on a button click" with no
|
|
626
|
+
// supported form at all. The gate is peeked, not read reactively, because
|
|
627
|
+
// this function is also called from detached callbacks (a focus handler, an
|
|
628
|
+
// invalidation subscriber) where a tracked read would attach the gate to
|
|
629
|
+
// whatever effect happened to be running; the effect below does the tracked
|
|
630
|
+
// read.
|
|
631
|
+
async function fetchQuery({ force = false, manual = false } = {}) {
|
|
632
|
+
if (!manual && !gate.peek()) return;
|
|
373
633
|
|
|
374
634
|
// Check if data is still fresh
|
|
375
635
|
const now = Date.now();
|
|
376
|
-
if (cacheS.peek() != null && now - lastFetchTime < staleTime) {
|
|
636
|
+
if (!force && cacheS.peek() != null && now - lastFetchTime < staleTime) {
|
|
377
637
|
return cacheS.peek();
|
|
378
638
|
}
|
|
379
639
|
|
|
380
|
-
//
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
640
|
+
// Supersede whatever this hook had in flight: one request per hook, as
|
|
641
|
+
// before, whoever asked for it. Superseding REPLACES the request, which is
|
|
642
|
+
// why it may do this to a manual one; the effect cleanup below only
|
|
643
|
+
// cancels, which is why it may not.
|
|
644
|
+
if (inFlight) inFlight.controller.abort();
|
|
645
|
+
const controller = new AbortController();
|
|
646
|
+
inFlight = { controller, manual };
|
|
647
|
+
const { signal: abortSignal } = controller;
|
|
384
648
|
|
|
385
649
|
fetchStatus.set('fetching');
|
|
386
650
|
if (cacheS.peek() == null) {
|
|
387
|
-
|
|
651
|
+
rawStatus.set('loading');
|
|
388
652
|
}
|
|
389
653
|
|
|
390
654
|
let attempts = 0;
|
|
@@ -396,7 +660,7 @@ export function useQuery(options) {
|
|
|
396
660
|
batch(() => {
|
|
397
661
|
cacheS.set(result); // Updates all components reading this key
|
|
398
662
|
error.set(null);
|
|
399
|
-
|
|
663
|
+
rawStatus.set('success');
|
|
400
664
|
fetchStatus.set('idle');
|
|
401
665
|
});
|
|
402
666
|
lastFetchTime = Date.now();
|
|
@@ -405,8 +669,13 @@ export function useQuery(options) {
|
|
|
405
669
|
if (onSuccess) onSuccess(result);
|
|
406
670
|
if (onSettled) onSettled(result, null);
|
|
407
671
|
|
|
408
|
-
// Schedule cache cleanup (only if no active subscribers)
|
|
409
|
-
|
|
672
|
+
// Schedule cache cleanup (only if no active subscribers).
|
|
673
|
+
//
|
|
674
|
+
// Replaces the previous timer instead of arming another: every
|
|
675
|
+
// successful fetch used to add one and clear none, so a polling query
|
|
676
|
+
// accumulated pending Timeout objects at roughly (fetch rate x cacheTime).
|
|
677
|
+
if (cleanupTimer) clearTimeout(cleanupTimer);
|
|
678
|
+
cleanupTimer = unrefTimer(setTimeout(() => {
|
|
410
679
|
if (Date.now() - lastFetchTime >= cacheTime) {
|
|
411
680
|
const subs = revalidationSubscribers.get(key);
|
|
412
681
|
if (!subs || subs.size === 0) {
|
|
@@ -417,7 +686,7 @@ export function useQuery(options) {
|
|
|
417
686
|
lastFetchTimestamps.delete(key);
|
|
418
687
|
}
|
|
419
688
|
}
|
|
420
|
-
}, cacheTime);
|
|
689
|
+
}, cacheTime));
|
|
421
690
|
|
|
422
691
|
return result;
|
|
423
692
|
} catch (e) {
|
|
@@ -426,7 +695,7 @@ export function useQuery(options) {
|
|
|
426
695
|
if (attempts < retry) {
|
|
427
696
|
// Abort-aware retry delay: cancel the wait if the component unmounts
|
|
428
697
|
await new Promise((resolve, reject) => {
|
|
429
|
-
const id = setTimeout(resolve, retryDelay(attempts));
|
|
698
|
+
const id = unrefTimer(setTimeout(resolve, retryDelay(attempts)));
|
|
430
699
|
abortSignal.addEventListener('abort', () => {
|
|
431
700
|
clearTimeout(id);
|
|
432
701
|
reject(new DOMException('Aborted', 'AbortError'));
|
|
@@ -438,7 +707,7 @@ export function useQuery(options) {
|
|
|
438
707
|
|
|
439
708
|
batch(() => {
|
|
440
709
|
error.set(e);
|
|
441
|
-
|
|
710
|
+
rawStatus.set('error');
|
|
442
711
|
fetchStatus.set('idle');
|
|
443
712
|
});
|
|
444
713
|
|
|
@@ -449,23 +718,86 @@ export function useQuery(options) {
|
|
|
449
718
|
}
|
|
450
719
|
}
|
|
451
720
|
|
|
452
|
-
|
|
721
|
+
try {
|
|
722
|
+
return await attemptFetch();
|
|
723
|
+
} finally {
|
|
724
|
+
// Release ownership, but only if this request is still the current one:
|
|
725
|
+
// a newer fetch may already have superseded it while it was awaiting.
|
|
726
|
+
if (inFlight && inFlight.controller === controller) inFlight = null;
|
|
727
|
+
}
|
|
453
728
|
}
|
|
454
729
|
|
|
455
|
-
//
|
|
456
|
-
|
|
730
|
+
// clearCache() empties this key's shared signals, but the request this hook
|
|
731
|
+
// already has in flight is local to it and went on running: it landed a
|
|
732
|
+
// moment after the clear and wrote the previous user's data back into the
|
|
733
|
+
// entry the component is reading. See registerClearCacheHandler.
|
|
734
|
+
//
|
|
735
|
+
// A manual refetch() is cancelled too, unlike in the effect cleanup below.
|
|
736
|
+
// The caller's promise then resolves with undefined, which is the right trade
|
|
737
|
+
// against painting a logged-out user's data: clearCache() is a nuke, and
|
|
738
|
+
// useInfiniteQuery has always treated it as one.
|
|
739
|
+
function resetOnClearCache() {
|
|
740
|
+
if (inFlight) {
|
|
741
|
+
inFlight.controller.abort();
|
|
742
|
+
inFlight = null;
|
|
743
|
+
}
|
|
744
|
+
// An aborted fetch deliberately returns without touching either status
|
|
745
|
+
// signal, so nothing else would ever clear them. fetchStatus is the one
|
|
746
|
+
// that must not be left behind: the derived status above reads it to decide
|
|
747
|
+
// whether an emptied entry is being refilled, so 'fetching' would describe
|
|
748
|
+
// a request that no longer exists as a load in progress, and render a
|
|
749
|
+
// spinner that never comes down.
|
|
750
|
+
batch(() => {
|
|
751
|
+
if (rawStatus.peek() !== 'idle') rawStatus.set('idle');
|
|
752
|
+
if (fetchStatus.peek() !== 'idle') fetchStatus.set('idle');
|
|
753
|
+
});
|
|
754
|
+
}
|
|
457
755
|
|
|
458
|
-
// Initial fetch
|
|
756
|
+
// Initial fetch, plus the invalidation subscription for this key.
|
|
757
|
+
// Subscribing inside the effect is deliberate: see the matching comment in
|
|
758
|
+
// useSWR above. A query whose queryFn reads a signal re-runs this effect, and
|
|
759
|
+
// a subscription created once outside it was cancelled by the first such
|
|
760
|
+
// re-run, leaving the query permanently deaf to invalidateQueries().
|
|
459
761
|
scopedEffect(() => {
|
|
460
|
-
|
|
762
|
+
const unsubscribe = subscribeToKey(key, () => fetchQuery({ force: true }).catch(() => {}));
|
|
763
|
+
const releaseHandler = registerClearCacheHandler(resetOnClearCache);
|
|
764
|
+
// Tracked read of the MIRRORED gate, so this effect re-runs when the gate
|
|
765
|
+
// actually flips (and when the query function's own signals move), not
|
|
766
|
+
// whenever some unrelated signal a gate thunk happens to touch moves.
|
|
767
|
+
if (gate()) {
|
|
461
768
|
fetchQuery().catch(() => {});
|
|
769
|
+
} else if (!inFlight?.manual) {
|
|
770
|
+
// Turning a query off settles it. The cleanup below aborted anything the
|
|
771
|
+
// effect itself started, and an aborted request deliberately returns
|
|
772
|
+
// without touching either status signal, so nothing else would ever clear
|
|
773
|
+
// them. A request refetch() owns is still running, though, so this must
|
|
774
|
+
// not report 'idle' over the top of one.
|
|
775
|
+
batch(() => {
|
|
776
|
+
if (rawStatus.peek() === 'loading') rawStatus.set('idle');
|
|
777
|
+
if (fetchStatus.peek() !== 'idle') fetchStatus.set('idle');
|
|
778
|
+
});
|
|
462
779
|
}
|
|
463
780
|
return () => {
|
|
464
|
-
|
|
781
|
+
// Cancel only what this effect started. Cancelling a manual refetch here
|
|
782
|
+
// destroyed it without replacing it, and the caller saw nothing at all.
|
|
783
|
+
if (inFlight && !inFlight.manual) {
|
|
784
|
+
inFlight.controller.abort();
|
|
785
|
+
inFlight = null;
|
|
786
|
+
}
|
|
465
787
|
unsubscribe();
|
|
788
|
+
releaseHandler();
|
|
466
789
|
};
|
|
467
790
|
});
|
|
468
791
|
|
|
792
|
+
// Unmount cancels everything, including the manual request the effect's own
|
|
793
|
+
// cleanup deliberately spares.
|
|
794
|
+
onComponentDispose(() => {
|
|
795
|
+
if (inFlight) {
|
|
796
|
+
inFlight.controller.abort();
|
|
797
|
+
inFlight = null;
|
|
798
|
+
}
|
|
799
|
+
});
|
|
800
|
+
|
|
469
801
|
// Refetch on focus
|
|
470
802
|
if (refetchOnWindowFocus && typeof window !== 'undefined') {
|
|
471
803
|
scopedEffect(() => {
|
|
@@ -482,9 +814,9 @@ export function useQuery(options) {
|
|
|
482
814
|
// Polling
|
|
483
815
|
if (refetchInterval) {
|
|
484
816
|
scopedEffect(() => {
|
|
485
|
-
const interval = setInterval(() => {
|
|
817
|
+
const interval = unrefTimer(setInterval(() => {
|
|
486
818
|
fetchQuery().catch(() => {});
|
|
487
|
-
}, refetchInterval);
|
|
819
|
+
}, refetchInterval));
|
|
488
820
|
return () => clearInterval(interval);
|
|
489
821
|
});
|
|
490
822
|
}
|
|
@@ -497,14 +829,38 @@ export function useQuery(options) {
|
|
|
497
829
|
isLoading: () => status() === 'loading',
|
|
498
830
|
isError: () => status() === 'error',
|
|
499
831
|
isSuccess: () => status() === 'success',
|
|
832
|
+
// A query that is disabled and has never resolved. Its complement used to
|
|
833
|
+
// be reported as isLoading(), which is why a disabled query rendered a
|
|
834
|
+
// spinner that never came down.
|
|
835
|
+
isIdle: () => status() === 'idle',
|
|
500
836
|
isFetching: () => fetchStatus() === 'fetching',
|
|
501
|
-
|
|
837
|
+
isEnabled: () => gate(),
|
|
838
|
+
// Explicit: never gated by `enabled`, and never answered from the freshness
|
|
839
|
+
// window. A manual "get me fresh data now" that a staleTime silently
|
|
840
|
+
// swallows is a no-op the caller has no way to see, which is the same bug
|
|
841
|
+
// class that made invalidateQueries() a no-op.
|
|
842
|
+
refetch: () => fetchQuery({ force: true, manual: true }),
|
|
502
843
|
};
|
|
503
844
|
}
|
|
504
845
|
|
|
505
846
|
// --- useInfiniteQuery Hook ---
|
|
506
847
|
// For paginated/infinite scroll data
|
|
507
848
|
|
|
849
|
+
// Every base query option used to fall into an unused `...rest`, so `enabled`,
|
|
850
|
+
// `select`, `retry`, `onSuccess` and the rest were accepted and silently
|
|
851
|
+
// dropped. Those are honoured here.
|
|
852
|
+
//
|
|
853
|
+
// DEFERRED, on purpose: joining the shared cache. `key` is normalized and used
|
|
854
|
+
// for the invalidation subscription, but the pages live in signals local to
|
|
855
|
+
// this hook rather than in cacheSignals, so staleTime/cacheTime, getQueryData,
|
|
856
|
+
// setQueryData and cross-component sharing do not reach an infinite query yet.
|
|
857
|
+
// The shared cache holds ONE value per key, and an infinite query holds a
|
|
858
|
+
// growing list plus its page params; storing that under the same key would
|
|
859
|
+
// collide head-on with a useQuery on the same key (each would serve the other
|
|
860
|
+
// the wrong shape) and hand setQueryData a structure it has no way to describe.
|
|
861
|
+
// That needs a cache entry with a declared kind, which is a design change, not
|
|
862
|
+
// a patch. The options that depend on it (staleTime, cacheTime, placeholderData,
|
|
863
|
+
// refetchOnWindowFocus, refetchInterval) are still not honoured.
|
|
508
864
|
export function useInfiniteQuery(options) {
|
|
509
865
|
const {
|
|
510
866
|
queryKey,
|
|
@@ -512,103 +868,311 @@ export function useInfiniteQuery(options) {
|
|
|
512
868
|
getNextPageParam,
|
|
513
869
|
getPreviousPageParam,
|
|
514
870
|
initialPageParam,
|
|
515
|
-
|
|
871
|
+
enabled = true,
|
|
872
|
+
select,
|
|
873
|
+
retry = 3,
|
|
874
|
+
retryDelay = (attempt) => Math.min(1000 * 2 ** attempt, 30000),
|
|
875
|
+
onSuccess,
|
|
876
|
+
onError,
|
|
877
|
+
onSettled,
|
|
516
878
|
} = options;
|
|
517
879
|
|
|
880
|
+
const gate = createGate(enabled);
|
|
881
|
+
|
|
518
882
|
const pages = signal([]);
|
|
519
|
-
|
|
883
|
+
// Starts EMPTY. It used to be seeded with initialPageParam AND appended to by
|
|
884
|
+
// the first fetch, so one page in produced [0, 0]: pageParams[i] no longer
|
|
885
|
+
// named pages[i], and anything walking the two together (a "load newer"
|
|
886
|
+
// control reading pageParams[0]) held a param for a page that did not exist.
|
|
887
|
+
const pageParams = signal([]);
|
|
520
888
|
const hasNextPage = signal(true);
|
|
521
889
|
const hasPreviousPage = signal(false);
|
|
522
890
|
const isFetchingNextPage = signal(false);
|
|
523
891
|
const isFetchingPreviousPage = signal(false);
|
|
892
|
+
// An infinite query had no failure surface at all: a rejected queryFn was
|
|
893
|
+
// swallowed by the effect's .catch() and the list just stayed empty forever,
|
|
894
|
+
// with no way for a component to tell "no results" from "the request failed".
|
|
895
|
+
// index.d.ts already promised error/status/isLoading here.
|
|
896
|
+
//
|
|
897
|
+
// These are local rather than the shared getErrorSignal(key), for the reason
|
|
898
|
+
// in the note above: an infinite query does not own the shared entry for its
|
|
899
|
+
// key, so writing errors into it would clobber a useQuery reading the same key.
|
|
900
|
+
const error = signal(null);
|
|
901
|
+
const status = signal(gate.peek() ? 'loading' : 'idle');
|
|
524
902
|
|
|
525
903
|
const key = normalizeQueryKey(queryKey);
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
904
|
+
// The page request in flight and who asked for it. See the matching note in
|
|
905
|
+
// useQuery: an explicit fetchNextPage()/refetch() belongs to the caller, and
|
|
906
|
+
// the effect's cleanup must not cancel one just because it re-ran.
|
|
907
|
+
let inFlight = null;
|
|
908
|
+
|
|
909
|
+
// clearCache() reaches an infinite query through here, because its pages
|
|
910
|
+
// never entered cacheSignals for clearCache to empty. Same nuke: drop the
|
|
911
|
+
// pages, drop the error, and cancel anything in flight -- a request issued
|
|
912
|
+
// for the PREVIOUS user would otherwise land after the clear and put their
|
|
913
|
+
// rows back on screen, which is the whole thing we are preventing.
|
|
914
|
+
function resetOnClearCache() {
|
|
915
|
+
if (inFlight) {
|
|
916
|
+
inFlight.controller.abort();
|
|
917
|
+
inFlight = null;
|
|
918
|
+
}
|
|
919
|
+
batch(() => {
|
|
920
|
+
pages.set([]);
|
|
921
|
+
pageParams.set([]);
|
|
922
|
+
hasNextPage.set(true);
|
|
923
|
+
hasPreviousPage.set(false);
|
|
924
|
+
isFetchingNextPage.set(false);
|
|
925
|
+
isFetchingPreviousPage.set(false);
|
|
926
|
+
error.set(null);
|
|
927
|
+
// Nothing is in flight and the clear started nothing, so 'idle' -- the
|
|
928
|
+
// same honest report useQuery derives for an emptied entry.
|
|
929
|
+
status.set('idle');
|
|
930
|
+
});
|
|
931
|
+
}
|
|
529
932
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
933
|
+
// `replace` is a per-call argument, not a flag on the hook. As a flag it was
|
|
934
|
+
// set before the fetch and cleared only on success, so a refetch that aborted
|
|
935
|
+
// or failed left "replace the whole list" armed for whichever fetchNextPage()
|
|
936
|
+
// ran next, which silently deleted every loaded page.
|
|
937
|
+
async function fetchPage(pageParam, direction = 'next', { replace = false, manual = false } = {}) {
|
|
938
|
+
// Supersede the previous page fetch, whoever asked for it.
|
|
939
|
+
if (inFlight) inFlight.controller.abort();
|
|
940
|
+
const controller = new AbortController();
|
|
941
|
+
// `direction` is recorded so the finally below can tell whether the request
|
|
942
|
+
// that replaced this one owns the same loading flag. See the note there.
|
|
943
|
+
inFlight = { controller, manual, direction };
|
|
944
|
+
const { signal: abortSignal } = controller;
|
|
535
945
|
|
|
536
946
|
const loading = direction === 'next' ? isFetchingNextPage : isFetchingPreviousPage;
|
|
537
947
|
loading.set(true);
|
|
948
|
+
// Only the first page is 'loading'. Paging through an existing list keeps
|
|
949
|
+
// status 'success' and reports itself through isFetchingNextPage, so a list
|
|
950
|
+
// does not blink back to a spinner every time it grows.
|
|
951
|
+
if (pages.peek().length === 0) status.set('loading');
|
|
952
|
+
|
|
953
|
+
let attempts = 0;
|
|
538
954
|
|
|
539
955
|
try {
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
956
|
+
// Retry loop, with the same accounting as useQuery: `retry` is the total
|
|
957
|
+
// number of attempts, and the wait between them is abort-aware so an
|
|
958
|
+
// unmount cancels the pending retry instead of firing it into a dead
|
|
959
|
+
// component.
|
|
960
|
+
for (;;) {
|
|
961
|
+
try {
|
|
962
|
+
const result = await queryFn({
|
|
963
|
+
queryKey: Array.isArray(queryKey) ? queryKey : [queryKey],
|
|
964
|
+
pageParam,
|
|
965
|
+
signal: abortSignal,
|
|
966
|
+
});
|
|
545
967
|
|
|
546
|
-
|
|
968
|
+
if (abortSignal.aborted) return;
|
|
547
969
|
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
}
|
|
970
|
+
batch(() => {
|
|
971
|
+
if (replace) {
|
|
972
|
+
// Refetch: replace all pages with fresh first page (SWR pattern —
|
|
973
|
+
// old pages stayed visible during fetch, now swap atomically)
|
|
974
|
+
pages.set([result]);
|
|
975
|
+
pageParams.set([pageParam]);
|
|
976
|
+
} else if (direction === 'next') {
|
|
977
|
+
pages.set([...pages.peek(), result]);
|
|
978
|
+
pageParams.set([...pageParams.peek(), pageParam]);
|
|
979
|
+
} else {
|
|
980
|
+
pages.set([result, ...pages.peek()]);
|
|
981
|
+
pageParams.set([pageParam, ...pageParams.peek()]);
|
|
982
|
+
}
|
|
562
983
|
|
|
563
|
-
|
|
564
|
-
|
|
984
|
+
const nextParam = getNextPageParam?.(result, pages.peek());
|
|
985
|
+
hasNextPage.set(nextParam !== undefined);
|
|
565
986
|
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
});
|
|
987
|
+
if (getPreviousPageParam) {
|
|
988
|
+
const prevParam = getPreviousPageParam(result, pages.peek());
|
|
989
|
+
hasPreviousPage.set(prevParam !== undefined);
|
|
990
|
+
}
|
|
571
991
|
|
|
572
|
-
|
|
992
|
+
error.set(null);
|
|
993
|
+
status.set('success');
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
if (onSuccess) onSuccess(result);
|
|
997
|
+
if (onSettled) onSettled(result, null);
|
|
998
|
+
|
|
999
|
+
return result;
|
|
1000
|
+
} catch (e) {
|
|
1001
|
+
if (abortSignal.aborted) return;
|
|
1002
|
+
attempts++;
|
|
1003
|
+
if (attempts < retry) {
|
|
1004
|
+
// Abort-aware retry delay: cancel the wait if the component unmounts
|
|
1005
|
+
await new Promise((resolve, reject) => {
|
|
1006
|
+
const id = unrefTimer(setTimeout(resolve, retryDelay(attempts)));
|
|
1007
|
+
abortSignal.addEventListener('abort', () => {
|
|
1008
|
+
clearTimeout(id);
|
|
1009
|
+
reject(new DOMException('Aborted', 'AbortError'));
|
|
1010
|
+
}, { once: true });
|
|
1011
|
+
}).catch((err) => { if (err.name === 'AbortError') return; throw err; });
|
|
1012
|
+
if (abortSignal.aborted) return;
|
|
1013
|
+
continue;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
batch(() => {
|
|
1017
|
+
error.set(e);
|
|
1018
|
+
status.set('error');
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
if (onError) onError(e);
|
|
1022
|
+
if (onSettled) onSettled(null, e);
|
|
1023
|
+
|
|
1024
|
+
throw e;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
573
1027
|
} finally {
|
|
574
|
-
|
|
1028
|
+
// Clear this direction's loading flag unless a LIVE request in the SAME
|
|
1029
|
+
// direction has taken it over.
|
|
1030
|
+
//
|
|
1031
|
+
// `if (!abortSignal.aborted)` alone was too blunt. It exists so that an
|
|
1032
|
+
// aborted fetch cannot report "not fetching" over the top of the fetch
|
|
1033
|
+
// that replaced it, which is only a risk when the replacement uses the
|
|
1034
|
+
// same flag. Every other abort left the flag stuck true with nothing
|
|
1035
|
+
// alive behind it: fetchPreviousPage() over a fetchNextPage() still in
|
|
1036
|
+
// flight left isFetchingNextPage() true for the rest of the page's life,
|
|
1037
|
+
// and so isFetching() with it, which is a "loading more" spinner that
|
|
1038
|
+
// never comes down. The effect's cleanup abort (a gate flip, or the query
|
|
1039
|
+
// function's own signals moving) does the same for whichever direction it
|
|
1040
|
+
// interrupted.
|
|
1041
|
+
const successor = inFlight && inFlight.controller !== controller ? inFlight : null;
|
|
1042
|
+
if (!abortSignal.aborted || !successor || successor.direction !== direction) {
|
|
1043
|
+
loading.set(false);
|
|
1044
|
+
}
|
|
1045
|
+
// Release ownership, unless a newer fetch already superseded this one.
|
|
1046
|
+
if (inFlight && inFlight.controller === controller) inFlight = null;
|
|
575
1047
|
}
|
|
576
1048
|
}
|
|
577
1049
|
|
|
578
|
-
//
|
|
1050
|
+
// Explicit call from application code, so never gated by `enabled` (the same
|
|
1051
|
+
// rule as useQuery's refetch). `manual` distinguishes the public refetch()
|
|
1052
|
+
// from the invalidation subscriber below, which is automatic fetching aimed
|
|
1053
|
+
// at a key and is owned by the effect.
|
|
1054
|
+
function refetchAll({ manual = false } = {}) {
|
|
1055
|
+
// Keep old pages visible during refetch (SWR pattern).
|
|
1056
|
+
// fetchPage swaps them atomically when the data arrives.
|
|
1057
|
+
return fetchPage(initialPageParam, 'next', { replace: true, manual });
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
const view = computed(() => {
|
|
1061
|
+
const raw = { pages: pages(), pageParams: pageParams() };
|
|
1062
|
+
return select ? select(raw) : raw;
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
// Initial fetch, plus the invalidation subscription for this key. See the
|
|
1066
|
+
// matching comment in useQuery for why the subscription lives INSIDE the
|
|
1067
|
+
// effect.
|
|
579
1068
|
scopedEffect(() => {
|
|
580
|
-
|
|
1069
|
+
// The normalized key was computed and then never used, so an infinite query
|
|
1070
|
+
// was invisible to invalidateQueries(). Refetching from the first page is
|
|
1071
|
+
// the only coherent answer for a list whose later pages may no longer exist
|
|
1072
|
+
// once the data behind it changed.
|
|
1073
|
+
const unsubscribe = subscribeToKey(key, () => {
|
|
1074
|
+
if (gate.peek()) refetchAll().catch(() => {});
|
|
1075
|
+
});
|
|
1076
|
+
const releaseHandler = registerClearCacheHandler(resetOnClearCache);
|
|
1077
|
+
// Tracked read of the MIRRORED gate: this effect re-runs when the gate
|
|
1078
|
+
// actually flips, not whenever some unrelated signal a gate thunk happens
|
|
1079
|
+
// to touch moves. See createGate.
|
|
1080
|
+
if (gate()) {
|
|
1081
|
+
// `replace`, because a re-run of this effect means an input to page one
|
|
1082
|
+
// changed (the query function read a signal that moved, or `enabled`
|
|
1083
|
+
// flipped). Appending in that case left a stale copy of page one sitting
|
|
1084
|
+
// above the fresh one, forever. On the first run the list is empty, so
|
|
1085
|
+
// replacing and appending are the same thing.
|
|
1086
|
+
fetchPage(initialPageParam, 'next', { replace: true }).catch(() => {});
|
|
1087
|
+
} else if (!inFlight?.manual) {
|
|
1088
|
+
// Turning it off settles it: the cleanup below aborted anything the
|
|
1089
|
+
// effect started, and an aborted page fetch returns without clearing its
|
|
1090
|
+
// own loading flag. A page an explicit call is still fetching is left
|
|
1091
|
+
// alone, and so is the status describing it.
|
|
1092
|
+
batch(() => {
|
|
1093
|
+
if (status.peek() === 'loading') status.set('idle');
|
|
1094
|
+
if (isFetchingNextPage.peek()) isFetchingNextPage.set(false);
|
|
1095
|
+
if (isFetchingPreviousPage.peek()) isFetchingPreviousPage.set(false);
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
581
1098
|
return () => {
|
|
582
|
-
|
|
1099
|
+
// Cancel only what this effect started; see the matching note in useQuery.
|
|
1100
|
+
if (inFlight && !inFlight.manual) {
|
|
1101
|
+
inFlight.controller.abort();
|
|
1102
|
+
inFlight = null;
|
|
1103
|
+
}
|
|
1104
|
+
unsubscribe();
|
|
1105
|
+
releaseHandler();
|
|
583
1106
|
};
|
|
584
1107
|
});
|
|
585
1108
|
|
|
1109
|
+
// Unmount cancels everything, including a page fetch the application asked
|
|
1110
|
+
// for that the effect's own cleanup spares.
|
|
1111
|
+
onComponentDispose(() => {
|
|
1112
|
+
if (inFlight) {
|
|
1113
|
+
inFlight.controller.abort();
|
|
1114
|
+
inFlight = null;
|
|
1115
|
+
}
|
|
1116
|
+
});
|
|
1117
|
+
|
|
586
1118
|
return {
|
|
587
|
-
data: () => (
|
|
1119
|
+
data: () => view(),
|
|
1120
|
+
error: () => error(),
|
|
1121
|
+
status: () => status(),
|
|
1122
|
+
isLoading: () => status() === 'loading',
|
|
1123
|
+
isError: () => status() === 'error',
|
|
1124
|
+
isSuccess: () => status() === 'success',
|
|
1125
|
+
isIdle: () => status() === 'idle',
|
|
1126
|
+
isFetching: () => isFetchingNextPage() || isFetchingPreviousPage(),
|
|
1127
|
+
isEnabled: () => gate(),
|
|
588
1128
|
hasNextPage: () => hasNextPage(),
|
|
589
1129
|
hasPreviousPage: () => hasPreviousPage(),
|
|
590
1130
|
isFetchingNextPage: () => isFetchingNextPage(),
|
|
591
1131
|
isFetchingPreviousPage: () => isFetchingPreviousPage(),
|
|
1132
|
+
// "Load more" is an explicit call from application code, so it is `manual`
|
|
1133
|
+
// for the same reason refetch() is: a re-run of the effect must not cancel
|
|
1134
|
+
// a page the user asked for and hand the caller back `undefined`.
|
|
1135
|
+
//
|
|
1136
|
+
// An EMPTY page list is answered by fetching page one, and the user's
|
|
1137
|
+
// getNextPageParam/getPreviousPageParam is not consulted at all.
|
|
1138
|
+
//
|
|
1139
|
+
// Both of these are ungated by `enabled`, like refetch(), but a disabled
|
|
1140
|
+
// query's state is always the empty list, and the empty list had no last
|
|
1141
|
+
// page to derive a param from. So they called the user's callback with
|
|
1142
|
+
// `undefined`, and the documented callback shape
|
|
1143
|
+
// `(lastPage) => lastPage.nextCursor` throws on it -- while a defensive
|
|
1144
|
+
// `lastPage?.nextCursor` returns undefined and made the call a silent
|
|
1145
|
+
// no-op that fetched nothing. Either way refetch() was the only thing that
|
|
1146
|
+
// could load a disabled list, so "explicit calls run either way" was true
|
|
1147
|
+
// of one of the three. There is exactly one page it can mean here, and it
|
|
1148
|
+
// is the first one; asking a getNextPageParam what follows a page that does
|
|
1149
|
+
// not exist is not a question it can answer.
|
|
1150
|
+
//
|
|
1151
|
+
// `replace`, because this IS page one: it must land as the whole list, not
|
|
1152
|
+
// as an append onto whatever a racing call left behind.
|
|
592
1153
|
fetchNextPage: async () => {
|
|
593
|
-
const
|
|
594
|
-
|
|
1154
|
+
const loaded = pages.peek();
|
|
1155
|
+
if (loaded.length === 0) {
|
|
1156
|
+
return fetchPage(initialPageParam, 'next', { replace: true, manual: true });
|
|
1157
|
+
}
|
|
1158
|
+
const nextParam = getNextPageParam?.(loaded[loaded.length - 1], loaded);
|
|
595
1159
|
if (nextParam !== undefined) {
|
|
596
|
-
return fetchPage(nextParam, 'next');
|
|
1160
|
+
return fetchPage(nextParam, 'next', { manual: true });
|
|
597
1161
|
}
|
|
598
1162
|
},
|
|
599
1163
|
fetchPreviousPage: async () => {
|
|
600
|
-
const
|
|
601
|
-
|
|
1164
|
+
const loaded = pages.peek();
|
|
1165
|
+
if (loaded.length === 0) {
|
|
1166
|
+
// Same reasoning, and the direction is kept so that a caller watching
|
|
1167
|
+
// isFetchingPreviousPage() still sees the request it made.
|
|
1168
|
+
return fetchPage(initialPageParam, 'previous', { replace: true, manual: true });
|
|
1169
|
+
}
|
|
1170
|
+
const prevParam = getPreviousPageParam?.(loaded[0], loaded);
|
|
602
1171
|
if (prevParam !== undefined) {
|
|
603
|
-
return fetchPage(prevParam, 'previous');
|
|
1172
|
+
return fetchPage(prevParam, 'previous', { manual: true });
|
|
604
1173
|
}
|
|
605
1174
|
},
|
|
606
|
-
refetch:
|
|
607
|
-
// Keep old pages visible during refetch (SWR pattern).
|
|
608
|
-
// The fetchPage callback swaps them atomically when data arrives.
|
|
609
|
-
isRefetching = true;
|
|
610
|
-
return fetchPage(initialPageParam);
|
|
611
|
-
},
|
|
1175
|
+
refetch: () => refetchAll({ manual: true }),
|
|
612
1176
|
};
|
|
613
1177
|
}
|
|
614
1178
|
|
|
@@ -627,7 +1191,12 @@ export function invalidateQueries(keyOrPredicate, options = {}) {
|
|
|
627
1191
|
const { hard = false, exact = false } = options;
|
|
628
1192
|
const keysToInvalidate = [];
|
|
629
1193
|
if (typeof keyOrPredicate === 'function') {
|
|
630
|
-
|
|
1194
|
+
// allKnownKeys, not cacheSignals: a query that has subscribed but not yet
|
|
1195
|
+
// resolved has no cache entry, and an infinite query never gets one at all
|
|
1196
|
+
// (its pages are local), so iterating the data Map made a predicate blind
|
|
1197
|
+
// to exactly the queries an invalidation is aimed at. The prefix branch
|
|
1198
|
+
// below already looks in both places.
|
|
1199
|
+
for (const key of allKnownKeys()) {
|
|
631
1200
|
if (keyOrPredicate(key)) keysToInvalidate.push(key);
|
|
632
1201
|
}
|
|
633
1202
|
} else if (Array.isArray(keyOrPredicate) && !exact) {
|
|
@@ -647,6 +1216,10 @@ export function invalidateQueries(keyOrPredicate, options = {}) {
|
|
|
647
1216
|
}
|
|
648
1217
|
|
|
649
1218
|
for (const key of keysToInvalidate) {
|
|
1219
|
+
// Before notifying anyone: every subscriber woken below reads this epoch, so
|
|
1220
|
+
// they agree on one refetch, and any request already in flight is now a
|
|
1221
|
+
// generation behind and cannot answer for them.
|
|
1222
|
+
bumpEpoch(key);
|
|
650
1223
|
// Hard invalidation clears data immediately (shows loading state)
|
|
651
1224
|
// Soft invalidation (default) keeps stale data visible during re-fetch (SWR pattern)
|
|
652
1225
|
if (hard && cacheSignals.has(key)) cacheSignals.get(key).set(null);
|
|
@@ -681,13 +1254,65 @@ export function getQueryData(key) {
|
|
|
681
1254
|
return cacheSignals.has(key) ? cacheSignals.get(key).peek() : undefined;
|
|
682
1255
|
}
|
|
683
1256
|
|
|
1257
|
+
// Empty the cache without detaching anything that is currently on screen.
|
|
1258
|
+
//
|
|
1259
|
+
// This used to .clear() the Maps. A mounted component captured its key's signal
|
|
1260
|
+
// OBJECTS when it mounted, so dropping the entries produced two failures at
|
|
1261
|
+
// once: the component kept displaying the old value (the signal it holds was
|
|
1262
|
+
// never reset, which is precisely wrong for the case clearCache exists for, a
|
|
1263
|
+
// logout), and the next getCacheSignal() for that key minted a FRESH signal, so
|
|
1264
|
+
// every later write (setQueryData, a sibling component's fetch, prefetchQuery)
|
|
1265
|
+
// landed somewhere the mounted component was not reading. That contradicts the
|
|
1266
|
+
// documented promise that components sharing a key share one set of signals.
|
|
1267
|
+
//
|
|
1268
|
+
// So: every key is EMPTIED IN PLACE, which is the only write that reaches what
|
|
1269
|
+
// is currently on screen. It is emptied to `undefined`, not `null`, so the
|
|
1270
|
+
// entry reads as ABSENT afterwards: getQueryData() answers `undefined` for a
|
|
1271
|
+
// cleared key whether or not the object had to be kept alive for a consumer.
|
|
1272
|
+
// That matters because "is anything still reading this?" is a guess, and it is
|
|
1273
|
+
// a wrong one for a hook created OUTSIDE a component (a module-scope store
|
|
1274
|
+
// query is never disposed, so its subscription is immortal); callers have no
|
|
1275
|
+
// business being able to tell those two cases apart.
|
|
1276
|
+
//
|
|
1277
|
+
// Dropping the entry is then purely about reclaiming memory, and the guess is
|
|
1278
|
+
// free to be wrong: a key whose emptied signals are kept reads exactly like one
|
|
1279
|
+
// whose entry went away.
|
|
1280
|
+
//
|
|
1281
|
+
// Deliberately NOT cleared: revalidationSubscribers. A subscriber is the live
|
|
1282
|
+
// wiring between a mounted component and its key; dropping it would deafen
|
|
1283
|
+
// every mounted query to invalidateQueries() forever, which is the same
|
|
1284
|
+
// detachment bug in the other channel. Subscriptions are owned by the effect
|
|
1285
|
+
// that created them and are released on unmount.
|
|
684
1286
|
export function clearCache() {
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
1287
|
+
batch(() => {
|
|
1288
|
+
for (const key of allKnownKeys()) {
|
|
1289
|
+
cacheSignals.get(key)?.set(undefined);
|
|
1290
|
+
errorSignals.get(key)?.set(null);
|
|
1291
|
+
validatingSignals.get(key)?.set(false);
|
|
1292
|
+
|
|
1293
|
+
const subs = revalidationSubscribers.get(key);
|
|
1294
|
+
if (subs !== undefined && subs.size > 0) {
|
|
1295
|
+
cacheTimestamps.set(key, Date.now());
|
|
1296
|
+
} else {
|
|
1297
|
+
cacheSignals.delete(key);
|
|
1298
|
+
errorSignals.delete(key);
|
|
1299
|
+
validatingSignals.delete(key);
|
|
1300
|
+
cacheTimestamps.delete(key);
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
// What walking the Maps above cannot reach: an infinite query's pages,
|
|
1305
|
+
// which live in signals local to the hook, and every hook's in-flight
|
|
1306
|
+
// REQUEST, which is not data at all but lands as data a moment later. See
|
|
1307
|
+
// registerClearCacheHandler.
|
|
1308
|
+
//
|
|
1309
|
+
// Last, and inside the batch: the handlers read the emptied signals to
|
|
1310
|
+
// decide what to settle to, so they must run after every key is emptied.
|
|
1311
|
+
for (const handler of clearCacheHandlers) handler();
|
|
1312
|
+
});
|
|
689
1313
|
lastFetchTimestamps.clear();
|
|
690
1314
|
inFlightRequests.clear();
|
|
1315
|
+
keyEpochs.clear();
|
|
691
1316
|
}
|
|
692
1317
|
|
|
693
1318
|
/**
|