@raf-query/core 0.1.1 → 0.1.3

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/CHANGELOG.md CHANGED
@@ -1,10 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.3](https://github.com/KATT/raf-query/compare/0.1.2...0.1.3) (2026-10-12)
4
+
5
+ ### Features
6
+
7
+ * **core:** cancel requests nobody reads anymore ([#27](https://github.com/KATT/raf-query/issues/27)) ([c3c1e4b](https://github.com/KATT/raf-query/commit/c3c1e4bb9250164b6df17d7d4b7bd4043f908961))
8
+
9
+ ## [0.1.2](https://github.com/KATT/raf-query/compare/0.1.1...0.1.2) (2026-10-12)
10
+
11
+ ### Features
12
+
13
+ - add isFetching(filter?) and useIsFetching(filter?) ([#25](https://github.com/KATT/raf-query/issues/25)) ([70c3b98](https://github.com/KATT/raf-query/commit/70c3b981027f717b1d619d5a2b23822f685c032a))
14
+
3
15
  ## [0.1.1](https://github.com/KATT/raf-query/compare/0.1.0...0.1.1) (2026-10-12)
4
16
 
5
17
  ### Features
6
18
 
7
- * **core:** add predicate to QueryFilter ([#24](https://github.com/KATT/raf-query/issues/24)) ([c0b9c18](https://github.com/KATT/raf-query/commit/c0b9c1829cb3b6446f765b151e520f0195af5276))
19
+ - **core:** add predicate to QueryFilter ([#24](https://github.com/KATT/raf-query/issues/24)) ([c0b9c18](https://github.com/KATT/raf-query/commit/c0b9c1829cb3b6446f765b151e520f0195af5276))
8
20
 
9
21
  ## [0.1.0](https://github.com/KATT/raf-query/compare/0.0.19...0.1.0) (2026-10-12)
10
22
 
package/README.md CHANGED
@@ -196,6 +196,16 @@ When the cache updates — refetch, invalidation, `setData` — the hook returns
196
196
 
197
197
  Queries do not refetch on mount. They refetch when a loader starts them again, when invalidated, on window focus or reconnect while stale, on `refetch()`, or on `refetchInterval`.
198
198
 
199
+ For an app-wide indicator, `useIsFetching()` returns how many queries have a request in flight, background refetches included, and re-renders when that changes. Pass a filter to count only some: `useIsFetching({ queryKey: ["comments"] })`. It's `0` on the server and during hydration, so the indicator never appears in server HTML. Outside React, `qc.isFetching(filter?)` returns the same number.
200
+
201
+ ```tsx
202
+ // Rendered once, in the root layout.
203
+ function GlobalProgress() {
204
+ const fetching = useIsFetching();
205
+ return fetching > 0 ? <ProgressBar /> : null;
206
+ }
207
+ ```
208
+
199
209
  ### Paged queries
200
210
 
201
211
  With an `infiniteQueryOptions` factory, `useQuery` returns the same tuple. The promise resolves to `{ pages, pageParams }`, and the controls add paging:
@@ -475,7 +485,7 @@ raf-query's cache is written in [Effect](https://effect.website): `QueryClient`
475
485
  - A `queryFn` can return an `Effect`. Cancelling the request interrupts it.
476
486
  - An Effect `queryFn`'s error type is the query's: `qc.effect.fetch(issueQuery, input)` fails with `IssueNotFound | Query.CancelledError`. `ErrorOf<TFactory>` names it; it's `unknown` for promises. A `queryFn` that throws instead of returning is a defect, and isn't retried.
477
487
  - `retry` accepts a `Schedule` as well as a count or predicate.
478
- - `qc.effect` has every verb as an Effect: `prefetch`, `ensure`, `fetch`, `invalidate`, `reset`, `remove`, `setData`, `getData` (an `Option`), `dehydrate`, `hydrate`. `watch` is a `Stream` of an entry's state, and `events` a `Stream` of cache events.
488
+ - `qc.effect` has every verb as an Effect: `prefetch`, `ensure`, `fetch`, `invalidate`, `reset`, `remove`, `setData`, `getData` (an `Option`), `isFetching` (a count), `dehydrate`, `hydrate`. `watch` is a `Stream` of an entry's state, and `events` a `Stream` of cache events.
479
489
  - SSR streaming is Effect too, and the integrations are thin wrappers over it. `dehydrateStream(options)` (scoped) returns the cache's `initial` state and a `Stream` of `updates`: entries that start or settle later, each sent once, batched per microtask. The stream ends when the scope closes. `hydrateStream(stream)` hydrates each state as it arrives. `Stream.toReadableStream` / `Stream.fromReadableStream` carry it over a web stream. The `sent` option (a `Set` of hashes) makes separate `dehydrate` calls of one request skip entries already sent.
480
490
  - Entries are a data type with module functions, as in Effect's own modules: `Query.fetch(query)`, `Query.refetch(query)`, `Query.invalidate(query)`, `Query.changes(query)`, and `Query.getUnsafe(query)` / `Query.subscribeUnsafe(query, listener)` for synchronous reads.
481
491
  - `QueryObserver` is a data type too: one reader's view of a query, as `useQuery` sees it (the controls and the promise, with `whilePending`, `refetchInterval` and the focus / reconnect options). `QueryObserver.make(query, options)` creates one, and `QueryObserver.changes(observer)` streams its results; while it runs, the query counts as read and polls on `refetchInterval`.
@@ -612,6 +622,7 @@ Both render nothing on the server, since the server's cache isn't the browser's.
612
622
  | `remove(factory, input)` / `remove(filter)` | Remove entries and abort their requests. [Filters](#filters) |
613
623
  | `effect` | The [Effect](#effect) `QueryCache` behind the client |
614
624
  | `subscribe(listener)` | Cache events: `added`, `updated`, `observers` (a reader subscribed or left), `removed`, and `failed` (with the `error`) |
625
+ | `isFetching(filter?)` | How many entries have a request in flight, optionally only those matching `{ queryKey, exact? }` |
615
626
  | `getAll()`, `getQuery(factory, input?)` | Cache entries, for devtools and integrations |
616
627
  | `clear()` | Drop every entry and abort in-flight requests |
617
628
  | `mount()` | Start window-focus and reconnect refetching. The providers call it |
@@ -633,6 +644,8 @@ Query options, accepted by `queryOptions`, `infiniteQueryOptions` and `config.de
633
644
 
634
645
  In the browser, retries pause while `navigator.onLine` is `false`: after the retry delay, the next attempt waits for the window's `online` event. The request stays fetching meanwhile, and cancelling it (`remove`, `clear`, a restart) stops the wait. The first attempt runs regardless.
635
646
 
647
+ When the last reader of a query unsubscribes during a background refetch, a `queryFn` that read its `signal` (or returned an Effect) is aborted: the entry keeps its data and goes back to how it was before the request, and `fetch` callers waiting on it get the cached data. Other requests, including every first load (a render may be suspended on it), finish so their result is cached, but don't retry unless a reader comes back. Requests nothing has subscribed to yet, like a loader's `prefetch`, keep running.
648
+
636
649
  `@raf-query/core`:
637
650
 
638
651
  | Export | Use |
@@ -663,6 +676,7 @@ In the browser, retries pause while `navigator.onLine` is `false`: after the ret
663
676
  | `useQueryClient()` | Read the client |
664
677
  | `useQuery(factory, handle, options?)` | `[Thenable<Data>, controls]`; for paged factories `Data` is `{ pages, pageParams }` and controls include paging |
665
678
  | `useWaterfall(factory, input)` | Start a query during render and return its handle |
679
+ | `useIsFetching(filter?)` | How many queries have a request in flight, re-rendering when it changes; for global loading indicators |
666
680
 
667
681
  ## Development
668
682
 
package/dist/cache.d.mts CHANGED
@@ -109,6 +109,13 @@ export interface QueryCache {
109
109
  * screen.
110
110
  */
111
111
  invalidate<TFactory extends QueryFactory>(...target: Target<TFactory>): Effect.Effect<void>;
112
+ /**
113
+ * How many entries have a request in flight: all of them, or those
114
+ * matching `filter`. For global loading indicators.
115
+ */
116
+ isFetching(filter?: Filter): Effect.Effect<number>;
117
+ /** `isFetching`, synchronously, for renders. */
118
+ isFetchingUnsafe(filter?: Filter): number;
112
119
  readonly isServer: boolean;
113
120
  /**
114
121
  * Refetch read, stale queries on window focus and reconnect while the
package/dist/cache.mjs CHANGED
@@ -11,8 +11,9 @@ import { Clock, Context, Effect, Layer, Option, Predicate, RcRef, Stream } from
11
11
  /**
12
12
  * The query cache, as an Effect service. `make` builds one in the current
13
13
  * scope; closing the scope interrupts every request and timer it started.
14
- * Everything that waits, or changes the cache, is an Effect; `getQueryUnsafe`
15
- * and `subscribeUnsafe` are for renders and external stores.
14
+ * Everything that waits, or changes the cache, is an Effect; `getQueryUnsafe`,
15
+ * `isFetchingUnsafe` and `subscribeUnsafe` are for renders and external
16
+ * stores.
16
17
  *
17
18
  * Requests run with the services the cache was built with, under those of
18
19
  * the latest verb that called for the entry. So the verbs that can start a
@@ -135,6 +136,7 @@ const make = Effect.fnUntraced(function* (config = {}) {
135
136
  };
136
137
  };
137
138
  const getQueryUnsafe = (factory, ...args) => typed(build(factory, args[0]));
139
+ const isFetchingUnsafe = (filter) => (filter ? match([filter]) : [...state.entries.values()]).filter((entry) => entry.state.isFetching).length;
138
140
  return {
139
141
  all: Effect.sync(() => [...state.entries.values()]),
140
142
  clear: Effect.sync(() => {
@@ -158,6 +160,8 @@ const make = Effect.fnUntraced(function* (config = {}) {
158
160
  hydrate: (dehydrated, options = {}) => Effect.sync(() => hydrateEntries(entryFor, dehydrated, options)),
159
161
  hydrateStream: (states, options = {}) => Stream.runForEach(states, (dehydrated) => Effect.sync(() => hydrateEntries(entryFor, dehydrated, options))),
160
162
  invalidate: (...target) => Effect.suspend(() => settleAll(match(target).map(invalidate))),
163
+ isFetching: (filter) => Effect.sync(() => isFetchingUnsafe(filter)),
164
+ isFetchingUnsafe,
161
165
  isServer: state.isServer,
162
166
  mount: state.isServer ? Effect.void : Effect.asVoid(RcRef.get(subscription)),
163
167
  prefetch: (factory, ...args) => Effect.map(buildFor(factory, args[0]), (entry) => {
package/dist/entry.d.mts CHANGED
@@ -54,6 +54,8 @@ export interface Entry extends Query {
54
54
  readonly commands: Commands;
55
55
  /** A failed first load was shown; the next read retries it. */
56
56
  errorShown: boolean;
57
+ /** `failureCount` before the request in flight, restored if it's cancelled. */
58
+ failureCountBefore: number;
57
59
  handle: QueryHandle;
58
60
  /** Awaited by every caller of the request in flight; outlives restarts. */
59
61
  inFlight: Deferred.Deferred<unknown, unknown> | undefined;
@@ -61,6 +63,8 @@ export interface Entry extends Query {
61
63
  options: AnyQueryOptions | undefined;
62
64
  /** The first load's promise, until it settles. */
63
65
  pending: Pending<unknown> | undefined;
66
+ /** The last reader left during the request in flight: it retries no more. */
67
+ retriesStopped: boolean;
64
68
  /** Open while the entry is cached; owns its fibers. */
65
69
  readonly scope: Scope.Closeable;
66
70
  /**
@@ -68,6 +72,11 @@ export interface Entry extends Query {
68
72
  * latest verb that called for this entry ({@link provide}).
69
73
  */
70
74
  services: Context.Context<never>;
75
+ /**
76
+ * The request in flight read its `signal`, or returned an Effect, so
77
+ * interrupting it cancels its work (TanStack's `abortSignalConsumed`).
78
+ */
79
+ signalRead: boolean;
71
80
  state: State<unknown>;
72
81
  readonly tasks: Tasks;
73
82
  }
package/dist/entry.mjs CHANGED
@@ -7,7 +7,7 @@ import { fromQueryFn, requestServices, runRequest } from "./request.mjs";
7
7
  import { retrySchedule } from "./retry.mjs";
8
8
  import { replaceEqualDeep } from "./share.mjs";
9
9
  import { fulfilled, pending } from "./thenable.mjs";
10
- import { Cause, Data, Deferred, Effect, Exit, FiberMap, Pipeable, Predicate, Scope } from "effect";
10
+ import { Cause, Data, Deferred, Effect, Exit, FiberMap, Pipeable, Predicate, Schedule, Scope } from "effect";
11
11
  //#region src/entry.ts
12
12
  const TypeId = "~@raf-query/core/Query";
13
13
  /**
@@ -55,6 +55,7 @@ function make(cache, key, hash) {
55
55
  const entry = Object.assign(Object.create(Proto), {
56
56
  cache,
57
57
  errorShown: false,
58
+ failureCountBefore: 0,
58
59
  handle: {
59
60
  input: void 0,
60
61
  key
@@ -65,8 +66,10 @@ function make(cache, key, hash) {
65
66
  listeners: /* @__PURE__ */ new Map(),
66
67
  options: void 0,
67
68
  pending: first,
69
+ retriesStopped: false,
68
70
  scope,
69
71
  services: cache.services,
72
+ signalRead: false,
70
73
  state: {
71
74
  data: void 0,
72
75
  error: null,
@@ -121,6 +124,7 @@ function hasPreviousPageUnsafe(entry) {
121
124
  }
122
125
  function subscribeUnsafe(entry, listener, options = defaultSubscribeOptions) {
123
126
  entry.listeners.set(listener, options);
127
+ entry.retriesStopped = false;
124
128
  entry.tasks.cancel("gc");
125
129
  scheduleStale(entry);
126
130
  emit(entry.cache, {
@@ -132,6 +136,8 @@ function subscribeUnsafe(entry, listener, options = defaultSubscribeOptions) {
132
136
  if (entry.listeners.size === 0) {
133
137
  entry.tasks.cancel("stale");
134
138
  scheduleGc(entry);
139
+ const inFlight = entry.inFlight;
140
+ if (inFlight) scheduleNotify(() => unobserved(entry, inFlight));
135
141
  }
136
142
  emit(entry.cache, {
137
143
  query: entry,
@@ -273,8 +279,11 @@ function set(entry, patch) {
273
279
  }
274
280
  /** Run `request` as the entry's request fiber, settling into its awaiter. */
275
281
  function startRequest(entry, request, direction) {
282
+ if (!entry.inFlight) entry.failureCountBefore = entry.state.failureCount;
276
283
  const awaiter = entry.inFlight ?? Deferred.makeUnsafe();
277
284
  entry.inFlight = awaiter;
285
+ entry.retriesStopped = false;
286
+ entry.signalRead = false;
278
287
  entry.tasks.cancel("gc");
279
288
  if (!entry.state.hasData && entry.state.status === "error") {
280
289
  entry.pending = pending();
@@ -312,6 +321,29 @@ function settle(entry, awaiter, exit) {
312
321
  }
313
322
  scheduleGc(entry);
314
323
  }
324
+ /**
325
+ * The last reader left while `awaiter`'s request was in flight. A refetch
326
+ * whose work an interrupt cancels (`signalRead`) is interrupted, and the
327
+ * entry goes back to its state before the request; its awaiters get the
328
+ * cached data. Any other request finishes, so its result is cached, but
329
+ * runs no more retries.
330
+ */
331
+ function unobserved(entry, awaiter) {
332
+ if (isObserved(entry) || entry.inFlight !== awaiter) return;
333
+ if (!entry.signalRead || !entry.state.hasData) {
334
+ entry.retriesStopped = true;
335
+ return;
336
+ }
337
+ entry.inFlight = void 0;
338
+ entry.tasks.cancel("request");
339
+ set(entry, {
340
+ failureCount: entry.failureCountBefore,
341
+ fetchDirection: void 0,
342
+ isFetching: false
343
+ });
344
+ Deferred.doneUnsafe(awaiter, Exit.succeed(entry.state.data));
345
+ scheduleGc(entry);
346
+ }
315
347
  function succeed(entry, next, updatedAt) {
316
348
  const previous = entry.state;
317
349
  const data = previous.hasData && behavior(entry, "structuralSharing") !== false ? replaceEqualDeep(previous.data, next) : next;
@@ -365,24 +397,37 @@ function markErrorShown(entry) {
365
397
  }
366
398
  function requestEffect(entry, options, request) {
367
399
  const queryKey = options.queryKey;
368
- if (options.kind === "query") return attempt(entry, (signal) => options.queryFn({
400
+ if (options.kind === "query") return attempt(entry, (readSignal) => options.queryFn({
369
401
  queryKey,
370
- signal
402
+ get signal() {
403
+ return readSignal();
404
+ }
371
405
  }));
372
406
  const current = isInfiniteData(entry.state.data) ? entry.state.data : void 0;
373
- return fetchPages(options, current, request, (pageParam, direction) => attempt(entry, (signal) => options.queryFn({
407
+ return fetchPages(options, current, request, (pageParam, direction) => attempt(entry, (readSignal) => options.queryFn({
374
408
  direction,
375
409
  pageParam,
376
410
  queryKey,
377
- signal
411
+ get signal() {
412
+ return readSignal();
413
+ }
378
414
  })));
379
415
  }
380
416
  /**
381
417
  * One `queryFn` call, retried per the query's policy. Every failure counts.
382
- * Its services are erased with its data type; `runRequest` provides them.
418
+ * `call` reads the signal through `readSignal`, which marks the entry's
419
+ * `signalRead`. Its services are erased with its data type; `runRequest`
420
+ * provides them.
383
421
  */
384
422
  function attempt(entry, call) {
385
- const once = fromQueryFn(call).pipe(Effect.tapError(() => Effect.sync(() => set(entry, { failureCount: entry.state.failureCount + 1 }))));
423
+ const once = fromQueryFn((signal) => {
424
+ const result = call(() => {
425
+ entry.signalRead = true;
426
+ return signal;
427
+ });
428
+ if (Effect.isEffect(result)) entry.signalRead = true;
429
+ return result;
430
+ }).pipe(Effect.tapError(() => Effect.sync(() => set(entry, { failureCount: entry.state.failureCount + 1 }))));
386
431
  const schedule = retrySchedule({
387
432
  retry: behavior(entry, "retry"),
388
433
  retryDelay: behavior(entry, "retryDelay")
@@ -391,12 +436,18 @@ function attempt(entry, call) {
391
436
  const online = entry.cache.isServer ? Effect.void : whenOnline;
392
437
  return Effect.suspend(() => {
393
438
  let retrying = false;
439
+ let lastError;
440
+ const unlessStopped = (next) => Effect.suspend(() => entry.retriesStopped ? Effect.fail(lastError) : next);
394
441
  const gated = Effect.suspend(() => {
395
- const wait = retrying ? online : Effect.void;
396
- retrying = true;
397
- return Effect.andThen(wait, once);
398
- });
399
- return Effect.retry(gated, schedule);
442
+ if (!retrying) {
443
+ retrying = true;
444
+ return once;
445
+ }
446
+ return unlessStopped(Effect.andThen(online, unlessStopped(once)));
447
+ }).pipe(Effect.tapError((error) => Effect.sync(() => {
448
+ lastError = error;
449
+ })));
450
+ return Effect.retry(gated, Schedule.while(schedule, () => !entry.retriesStopped));
400
451
  });
401
452
  }
402
453
  function scheduleGc(entry) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raf-query/core",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Framework-agnostic core of raf-query, a render-as-you-fetch query library",
5
5
  "keywords": [
6
6
  "query",