ngx-signal-query 1.4.0 → 1.5.0-dev.2

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/README.md CHANGED
@@ -1,22 +1,39 @@
1
1
  # ngx-signal-query
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/ngx-signal-query.svg)](https://www.npmjs.com/package/ngx-signal-query)
4
+ [![bundle size](https://deno.bundlejs.com/badge?q=ngx-signal-query&config=%7B%22esbuild%22%3A%7B%22external%22%3A%5B%22%40angular/core%22%2C%22%40angular/common%22%2C%22rxjs%22%2C%22rxjs/operators%22%2C%22tslib%22%5D%7D%7D)](https://bundlejs.com/?q=ngx-signal-query&config=%7B%22esbuild%22%3A%7B%22external%22%3A%5B%22%40angular/core%22%2C%22%40angular/common%22%2C%22rxjs%22%2C%22rxjs/operators%22%2C%22tslib%22%5D%7D%7D)
4
5
  [![license](https://img.shields.io/npm/l/ngx-signal-query.svg)](./LICENSE)
5
6
  [![docs](https://img.shields.io/badge/docs-ngx--signal--query-blue)](https://dhutaryan.github.io/ngx-signal-query/)
6
7
 
7
- A tiny, signal-native library for data fetching, caching, and mutations in
8
- Angular — inspired by [TanStack Query](https://tanstack.com/query). Built
9
- directly on Angular Signals, with first-class RxJS support: your `queryFn` can
10
- return an `Observable` (hello `HttpClient`) or a `Promise`.
8
+ A data fetching, caching and mutation library for Angular, built on signals and
9
+ RxJS. Queries and mutations take an `Observable` as is, so `HttpClient` works
10
+ without `lastValueFrom`, and when no component needs a query any more, its
11
+ request is cancelled. The model (query keys, staleness, invalidation) follows
12
+ [TanStack Query](https://tanstack.com/query), so what you know from it carries
13
+ over.
14
+
15
+ ## Why this one
16
+
17
+ - 🔌 **Observables end to end.** Return `http.get()` from a `queryFn` or `mutationFn`. For a query, unsubscribing is the cancellation: once no component needs it, the request is aborted, with no `AbortSignal` plumbing.
18
+ - 🅰ïļ **Angular all the way down.** State is signals, configuration is `provideQueryClient(withDefaultOptions(...))`, and default mutation hooks run in an injection context, so a global `onError` can `inject()` what it needs.
19
+ - ðŸŠķ **Small.** About 5 KB min+gzip, tree-shakable ESM, no runtime dependencies beyond `tslib`.
20
+ - 🧭 **Familiar concepts.** `queryKey`, `staleTime`, `invalidateQueries` and `placeholderData` mean what you'd expect. Moving to TanStack Query later is mostly mechanical: wrap Observables in `lastValueFrom` and swap the provider.
11
21
 
12
22
  ## Features
13
23
 
14
- - ðŸšĶ **Signal-native** — query state is exposed as signals (`data()`, `status()`, â€Ķ), no manual subscriptions.
15
- - 🗄ïļ **Caching & deduplication** — identical query keys share a single in-flight request and cache entry.
16
- - ✏ïļ **Mutations** — with `onMutate` / `onSuccess` / `onError` lifecycle for optimistic updates.
17
- - 🔁 **Retries & polling** — configurable `retry` and `refetchInterval`.
18
- - 🌍 **Global indicators** — `injectIsFetching()` / `injectIsMutating()` for app-wide loading state.
19
- - ðŸŠķ **Lean** — ESM, `sideEffects: false`, no runtime deps beyond `tslib`.
24
+ - ðŸšĶ State as signals: read `data()` or `isPending()` in a template or a `computed`, no subscriptions
25
+ - 🗄ïļ Caching, request deduplication and structural sharing
26
+ - â™ŧïļ Invalidation, cancellation and manual cache updates
27
+ - 🔗 Dependent, parallel (`injectQueries`) and disabled queries
28
+ - 🖞ïļ Seed and placeholder data while a query loads (`initialData`, `placeholderData`, `keepPreviousData`)
29
+ - 🔁 Retries, polling, and refetch on window focus and reconnect
30
+ - ✏ïļ Mutations with async lifecycle hooks and optimistic updates
31
+ - 🌍 App-wide defaults and global indicators (`injectIsFetching()`, `injectIsMutating()`)
32
+
33
+ ## Not here (yet)
34
+
35
+ Infinite queries, SSR cache hydration, devtools and persistence. If you need
36
+ them today, TanStack Query has them.
20
37
 
21
38
  ## Installation
22
39
 
@@ -77,26 +94,39 @@ export class TodosComponent {
77
94
  }
78
95
  ```
79
96
 
80
- ## Mutations
97
+ ### 3. Mutate and reconcile the cache
98
+
99
+ `injectMutation` works the same way: call `mutate()`, read `isPending()` /
100
+ `status()` as signals, and invalidate affected queries once it succeeds.
81
101
 
82
102
  ```ts
83
- import {
84
- mutationOptions,
85
- injectMutation,
86
- injectQueryClient,
87
- } from 'ngx-signal-query'
88
-
89
- const client = injectQueryClient()
90
-
91
- const addTodo = injectMutation(() =>
92
- mutationOptions({
93
- mutationFn: (title: string) => http.post<Todo>('/api/todos', { title }),
94
- onSuccess: () => client.invalidateQueries({ queryKey: ['todos'] }),
95
- }),
96
- )
97
-
98
- // in a template
99
- // <button (click)="addTodo.mutate('Buy milk')" [disabled]="addTodo.isPending()">Add</button>
103
+ import { Component, inject } from '@angular/core'
104
+ import { HttpClient } from '@angular/common/http'
105
+ import { injectMutation, injectQueryClient } from 'ngx-signal-query'
106
+
107
+ type Todo = { id: number; title: string }
108
+
109
+ @Component({
110
+ selector: 'app-add-todo',
111
+ template: `
112
+ <button
113
+ (click)="addTodo.mutate('Buy milk')"
114
+ [disabled]="addTodo.isPending()"
115
+ >
116
+ Add todo
117
+ </button>
118
+ `,
119
+ })
120
+ export class AddTodoComponent {
121
+ private readonly http = inject(HttpClient)
122
+ private readonly client = injectQueryClient()
123
+
124
+ protected readonly addTodo = injectMutation(() => ({
125
+ mutationFn: (title: string) =>
126
+ this.http.post<Todo>('/api/todos', { title }),
127
+ onSuccess: () => this.client.invalidateQueries({ queryKey: ['todos'] }),
128
+ }))
129
+ }
100
130
  ```
101
131
 
102
132
  ## Documentation
@@ -1,6 +1,54 @@
1
1
  import * as i0 from '@angular/core';
2
- import { signal, Injectable, InjectionToken, inject, EnvironmentInjector, DestroyRef, untracked, runInInjectionContext, makeEnvironmentProviders, assertInInjectionContext, effect, computed, Injector } from '@angular/core';
3
- import { defer, from, take, retry, timer, throwIfEmpty, map, tap, concatMap, catchError, of, endWith, concat, ignoreElements, EMPTY } from 'rxjs';
2
+ import { inject, PLATFORM_ID, signal, Injectable, InjectionToken, EnvironmentInjector, DestroyRef, untracked, runInInjectionContext, makeEnvironmentProviders, assertInInjectionContext, effect, computed, Injector } from '@angular/core';
3
+ import { DOCUMENT, isPlatformBrowser } from '@angular/common';
4
+ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
5
+ import { fromEvent, filter, defer, from, take, retry, timer, throwIfEmpty, map, tap, concatMap, catchError, of, endWith, concat, ignoreElements, EMPTY } from 'rxjs';
6
+
7
+ /**
8
+ * Calls `listener` whenever the user comes back to the page, as the tab or
9
+ * the window turns visible again. The window's `focus` event isn't used: it
10
+ * also fires when an alert or a file picker closes, or on coming back from
11
+ * DevTools, with the user on the page all along.
12
+ *
13
+ * Listens until the current injector is destroyed, and only in the browser:
14
+ * on the server there is no page to come back to. Must run in an injection
15
+ * context.
16
+ *
17
+ * @internal
18
+ */
19
+ function onWindowFocus(listener) {
20
+ if (!inBrowser())
21
+ return;
22
+ const document = inject(DOCUMENT);
23
+ fromEvent(document, 'visibilitychange')
24
+ .pipe(filter(() => document.visibilityState !== 'hidden'), takeUntilDestroyed())
25
+ .subscribe(() => listener());
26
+ }
27
+ /**
28
+ * Calls `listener` whenever the browser reports that the network is back
29
+ * (`online`). Each such event means it was off: the browser fires it only
30
+ * when going from offline to online, a page opened offline included.
31
+ *
32
+ * Listens until the current injector is destroyed, and only in the browser.
33
+ * Must run in an injection context.
34
+ *
35
+ * @internal
36
+ */
37
+ function onReconnect(listener) {
38
+ if (!inBrowser())
39
+ return;
40
+ const window = inject(DOCUMENT).defaultView;
41
+ if (!window)
42
+ return;
43
+ fromEvent(window, 'online')
44
+ .pipe(takeUntilDestroyed())
45
+ .subscribe(() => listener());
46
+ }
47
+ // On the server, DOCUMENT is an emulated document that has listeners,
48
+ // a window and no visibilityState: only the platform tells.
49
+ function inBrowser() {
50
+ return isPlatformBrowser(inject(PLATFORM_ID));
51
+ }
4
52
 
5
53
  /** @internal */
6
54
  class Cache {
@@ -206,6 +254,28 @@ function resolveRetryDelay(retryDelay, failureCount, error) {
206
254
  }
207
255
 
208
256
  const DEFAULT_GC_TIME = 5 * 60 * 1000;
257
+ /**
258
+ * What the promise of a query's fetch rejects with when the fetch stops with
259
+ * no data to give: cancelled before the query had any, or thrown away with
260
+ * the query.
261
+ *
262
+ * @internal
263
+ */
264
+ class CancelledError extends Error {
265
+ constructor() {
266
+ super('The query fetch was cancelled');
267
+ this.name = 'CancelledError';
268
+ }
269
+ }
270
+ function deferred() {
271
+ let resolve;
272
+ let reject;
273
+ const promise = new Promise((res, rej) => {
274
+ resolve = res;
275
+ reject = rej;
276
+ });
277
+ return { promise, resolve, reject };
278
+ }
209
279
  /** @internal */
210
280
  class Query {
211
281
  key;
@@ -225,6 +295,9 @@ class Query {
225
295
  // eslint-disable-next-line @typescript-eslint/member-ordering
226
296
  state = this.#state.asReadonly();
227
297
  #subscription = null;
298
+ // The promise of the fetch in flight, which every caller that joins the
299
+ // fetch shares, and how to settle it; null while nothing is in flight.
300
+ #outcome = null;
228
301
  // The structuralSharing of the fetch whose response the query holds.
229
302
  #fetchedStructuralSharing;
230
303
  #observers = new Set();
@@ -268,6 +341,17 @@ class Query {
268
341
  }
269
342
  return null;
270
343
  }
344
+ // Refetches the query now that the user is back on the page, if one of its
345
+ // observers wants that: its refetchOnWindowFocus is 'always', or true while
346
+ // the data is stale for its staleTime. The first such observer decides
347
+ // how, as in TanStack.
348
+ onFocus() {
349
+ this.#refetchOn('refetchOnWindowFocus');
350
+ }
351
+ // The same once the network is back, by refetchOnReconnect.
352
+ onOnline() {
353
+ this.#refetchOn('refetchOnReconnect');
354
+ }
271
355
  // How data written into the query by hand merges with what it holds: as
272
356
  // its first observer says, enabled or not; with none, the way its data was
273
357
  // fetched; undefined if neither has a say.
@@ -279,14 +363,28 @@ class Query {
279
363
  }
280
364
  // Without a structuralSharing setting the response is stored as is: the
281
365
  // default is the client's to resolve.
366
+ //
367
+ // Returns a promise of the data as the query stores it, which every caller
368
+ // that joins the fetch shares. A fetch that replaces this one settles it
369
+ // with its own outcome; one stopped with nothing in its place settles it as
370
+ // cancel() or destroy() says. Nobody has to wait for it.
282
371
  fetch({ queryFn, retry: retry$1 = 0, retryDelay = defaultRetryDelay, structuralSharing = false, }, { cancelRefetch = false } = {}) {
283
- if (this.#subscription && !this.#subscription.closed) {
372
+ const running = this.#outcome;
373
+ if (running) {
284
374
  // Already in-flight: dedupe unless the caller explicitly wants a fresh
285
- // fetch (e.g. invalidate/refetch) — then cancel the old one and restart.
375
+ // fetch (e.g. invalidate/refetch) — then replace the old one.
286
376
  if (!cancelRefetch)
287
- return;
288
- this.cancel();
377
+ return running.promise;
378
+ this.#subscription?.unsubscribe();
289
379
  }
380
+ const outcome = deferred();
381
+ // Its result or error lands in the state either way, so a caller may
382
+ // ignore the promise: handled here, a failure doesn't surface as an
383
+ // unhandled rejection, and whoever awaits the promise still gets it.
384
+ outcome.promise.catch(() => undefined);
385
+ // Whoever waits for the fetch this one replaces gets this one's outcome.
386
+ running?.resolve(outcome.promise);
387
+ this.#outcome = outcome;
290
388
  this.#state.update((state) => ({
291
389
  ...state,
292
390
  isFetching: true,
@@ -318,6 +416,7 @@ class Query {
318
416
  map((data) => replaceData(this.state().data, data, structuralSharing)))
319
417
  .subscribe({
320
418
  next: (data) => {
419
+ this.#outcome = null;
321
420
  this.#fetchedStructuralSharing = structuralSharing;
322
421
  this.#state.set({
323
422
  data,
@@ -329,14 +428,20 @@ class Query {
329
428
  failureReason: null,
330
429
  updatedAt: Date.now(),
331
430
  });
431
+ outcome.resolve(data);
432
+ },
433
+ error: (err) => {
434
+ this.#outcome = null;
435
+ this.#state.update((state) => ({
436
+ ...state,
437
+ status: 'error',
438
+ error: err,
439
+ isFetching: false,
440
+ }));
441
+ outcome.reject(err);
332
442
  },
333
- error: (err) => this.#state.update((state) => ({
334
- ...state,
335
- status: 'error',
336
- error: err,
337
- isFetching: false,
338
- })),
339
443
  });
444
+ return outcome.promise;
340
445
  }
341
446
  // Without a structuralSharing setting the data is stored as is: which one
342
447
  // a write uses is the client's to resolve.
@@ -365,17 +470,18 @@ class Query {
365
470
  state.isInvalidated ||
366
471
  this.#isStale(staleTime));
367
472
  }
473
+ // Stops the fetch in flight. Whoever waits for it gets the data the query
474
+ // holds, as in TanStack, or a CancelledError if it holds none.
368
475
  cancel() {
369
- this.#subscription?.unsubscribe();
370
- this.#subscription = null;
371
- // The fetch is no longer running; clear the in-flight flag so isFetching
372
- // doesn't stay stuck true after a cancellation.
373
- if (this.state().isFetching) {
374
- this.#state.update((state) => ({ ...state, isFetching: false }));
375
- }
476
+ const { data } = this.state();
477
+ this.#stop((outcome) => data === undefined
478
+ ? outcome.reject(new CancelledError())
479
+ : outcome.resolve(data));
376
480
  }
481
+ // The query is thrown away with its data, so whoever waits for its fetch
482
+ // gets a CancelledError, as in TanStack.
377
483
  destroy() {
378
- this.cancel();
484
+ this.#stop((outcome) => outcome.reject(new CancelledError()));
379
485
  this.#clearGcTimer();
380
486
  }
381
487
  // Inclusive: data fetched exactly `staleTime` ago is already stale. This
@@ -385,6 +491,38 @@ class Query {
385
491
  #isStale(staleTime) {
386
492
  return Date.now() - this.state().updatedAt >= staleTime;
387
493
  }
494
+ #inFlight() {
495
+ return this.#outcome !== null;
496
+ }
497
+ // Stops the fetch in flight, if any, and settles its promise with settle.
498
+ #stop(settle) {
499
+ const outcome = this.#outcome;
500
+ this.#subscription?.unsubscribe();
501
+ this.#subscription = null;
502
+ this.#outcome = null;
503
+ if (outcome)
504
+ settle(outcome);
505
+ // The fetch is no longer running; clear the in-flight flag so isFetching
506
+ // doesn't stay stuck true after a cancellation.
507
+ if (this.state().isFetching) {
508
+ this.#state.update((state) => ({ ...state, isFetching: false }));
509
+ }
510
+ }
511
+ // A fetch in flight is kept, as in TanStack, and the observers aren't even
512
+ // asked: fetch() would drop the refetch anyway.
513
+ #refetchOn(option) {
514
+ if (this.#inFlight())
515
+ return;
516
+ for (const observer of this.#observers) {
517
+ const options = observer.fetchOptions();
518
+ if (!options || options[option] === false)
519
+ continue;
520
+ if (options[option] === 'always' || this.shouldFetch(options.staleTime)) {
521
+ this.fetch(options);
522
+ return;
523
+ }
524
+ }
525
+ }
388
526
  #scheduleGc() {
389
527
  this.#clearGcTimer();
390
528
  this.#gcTimer = setTimeout(() => {
@@ -671,6 +809,12 @@ class QueryClient {
671
809
  /** @internal */
672
810
  constructor() {
673
811
  inject(DestroyRef).onDestroy(() => (this.#destroyed = true));
812
+ // When the user comes back to the page, or the network comes back, each
813
+ // query refetches if one of its observers wants that. Untracked like the
814
+ // methods: an event fired from an effect mustn't make the effect depend
815
+ // on what the refetch reads.
816
+ onWindowFocus(() => untracked(() => this.#cache.getAll().forEach((query) => query.onFocus())));
817
+ onReconnect(() => untracked(() => this.#cache.getAll().forEach((query) => query.onOnline())));
674
818
  }
675
819
  /** Returns the underlying query cache. Advanced/internal use. */
676
820
  getQueryCache() {
@@ -682,8 +826,9 @@ class QueryClient {
682
826
  }
683
827
  /**
684
828
  * Merges per-query options with the configured defaults, filling in
685
- * `staleTime`, `gcTime`, `retry`, `retryDelay` and `structuralSharing`.
686
- * Used internally by {@link injectQuery}.
829
+ * `staleTime`, `gcTime`, `retry`, `retryDelay`, `structuralSharing`,
830
+ * `refetchOnWindowFocus` and `refetchOnReconnect`. Used internally by
831
+ * {@link injectQuery}.
687
832
  */
688
833
  defaultQueryOptions(options) {
689
834
  const defaults = this.#config.defaultOptions?.queries;
@@ -694,6 +839,8 @@ class QueryClient {
694
839
  retry: options.retry ?? defaults?.retry ?? 3,
695
840
  retryDelay: options.retryDelay ?? defaults?.retryDelay ?? defaultRetryDelay,
696
841
  structuralSharing: options.structuralSharing ?? this.#defaultStructuralSharing(),
842
+ refetchOnWindowFocus: options.refetchOnWindowFocus ?? defaults?.refetchOnWindowFocus ?? true,
843
+ refetchOnReconnect: options.refetchOnReconnect ?? defaults?.refetchOnReconnect ?? true,
697
844
  };
698
845
  }
699
846
  /**
@@ -727,14 +874,17 @@ class QueryClient {
727
874
  */
728
875
  fetchQuery(key, queryFn, options = {}) {
729
876
  untracked(() => {
730
- const defaults = this.#config.defaultOptions?.queries;
731
- const staleTime = options.staleTime ?? defaults?.staleTime ?? 0;
732
- const retry = options.retry ?? defaults?.retry ?? 3;
733
- const retryDelay = options.retryDelay ?? defaults?.retryDelay ?? defaultRetryDelay;
734
- const structuralSharing = options.structuralSharing ?? this.#defaultStructuralSharing();
877
+ // Resolved as a query's options are, so one set of defaults serves both.
878
+ // cancelRefetch is about this call, not an option of the query.
879
+ const { cancelRefetch, ...queryOptions } = options;
880
+ const defaulted = this.defaultQueryOptions({
881
+ ...queryOptions,
882
+ queryKey: key,
883
+ queryFn,
884
+ });
735
885
  const query = this.#cache.getOrCreate(key);
736
- if (query.shouldFetch(staleTime)) {
737
- query.fetch({ queryFn, retry, retryDelay, structuralSharing }, { cancelRefetch: options.cancelRefetch });
886
+ if (query.shouldFetch(defaulted.staleTime)) {
887
+ query.fetch(defaulted, { cancelRefetch });
738
888
  }
739
889
  });
740
890
  }
@@ -1102,17 +1252,26 @@ function createQueryObserver(optionsFn, injector) {
1102
1252
  return;
1103
1253
  switchTo(untracked(() => resolve(entry.key)));
1104
1254
  });
1105
- // How this observer would fetch q right now, from the live options; null
1106
- // while it is disabled, or once the key has moved on. Before the key effect
1107
- // above catches up, queryFn already fetches the next query's data, so q
1108
- // must not be fetched with it. Both refetches made on our behalf go by it:
1109
- // the invalidation one (through q's subscriber handle) and polling.
1255
+ // How this observer would fetch q right now, and when it wants q
1256
+ // refetched, from the live options; null while it is disabled, or once the
1257
+ // key has moved on. Before the key effect above catches up, queryFn already
1258
+ // fetches the next query's data, so q must not be fetched with it. Every
1259
+ // refetch made on our behalf goes by it: through q's subscriber handle the
1260
+ // invalidation one and the window focus and reconnect ones, and polling.
1110
1261
  const fetchOptionsFor = (q) => {
1111
- const { queryKey, queryFn, retry, retryDelay, structuralSharing, enabled } = untracked(defaultedOptions);
1262
+ const { queryKey, queryFn, retry, retryDelay, structuralSharing, staleTime, refetchOnWindowFocus, refetchOnReconnect, enabled, } = untracked(defaultedOptions);
1112
1263
  if (enabled === false || hashKey(queryKey) !== q.queryHash) {
1113
1264
  return null;
1114
1265
  }
1115
- return { queryFn, retry, retryDelay, structuralSharing };
1266
+ return {
1267
+ queryFn,
1268
+ retry,
1269
+ retryDelay,
1270
+ structuralSharing,
1271
+ staleTime,
1272
+ refetchOnWindowFocus,
1273
+ refetchOnReconnect,
1274
+ };
1116
1275
  };
1117
1276
  track((cleanup) => {
1118
1277
  const q = query();
@@ -1140,18 +1299,19 @@ function createQueryObserver(optionsFn, injector) {
1140
1299
  // Invalidation isn't a trigger: invalidateQueries() refetches each active
1141
1300
  // query once itself. A query it skipped (unobserved, or its observers
1142
1301
  // disabled) stays invalidated, so it fetches here once observed or enabled:
1143
- // shouldFetch sees the flag.
1302
+ // shouldFetch sees the flag. The key and queryFn come from one read of the
1303
+ // options, so the fetch can't go to another key's query, whatever order the
1304
+ // effects run in.
1144
1305
  track(() => {
1145
1306
  query();
1146
1307
  if (!enabled())
1147
1308
  return;
1148
- const { queryKey, queryFn, staleTime, retry, retryDelay, structuralSharing, } = untracked(defaultedOptions);
1149
- untracked(() => client.fetchQuery(queryKey, queryFn, {
1150
- staleTime,
1151
- retry,
1152
- retryDelay,
1153
- structuralSharing,
1154
- }));
1309
+ untracked(() => {
1310
+ const options = defaultedOptions();
1311
+ const q = resolve(options.queryKey);
1312
+ if (q.shouldFetch(options.staleTime))
1313
+ q.fetch(options);
1314
+ });
1155
1315
  });
1156
1316
  // The polling interval this observer asks for, or false for none. Memoized,
1157
1317
  // so the polling effect wakes only when the value changes. The function form
@@ -1234,18 +1394,14 @@ function createQueryObserver(optionsFn, injector) {
1234
1394
  isPlaceholderData: computed(() => resolved().isPlaceholderData),
1235
1395
  failureCount: computed(() => query().state().failureCount),
1236
1396
  failureReason: computed(() => query().state().failureReason),
1237
- // Force a fresh fetch regardless of staleTime, cancelling any in-flight
1238
- // request (explicit user intent — get current data).
1239
- refetch: () => {
1240
- const { queryKey, queryFn, retry, retryDelay, structuralSharing } = untracked(defaultedOptions);
1241
- client.fetchQuery(queryKey, queryFn, {
1242
- staleTime: 0,
1243
- retry,
1244
- retryDelay,
1245
- structuralSharing,
1246
- cancelRefetch: true,
1247
- });
1248
- },
1397
+ // Force a fresh fetch regardless of staleTime and enabled, cancelling
1398
+ // any in-flight request (explicit user intent — get current data).
1399
+ // Untracked, as the client's methods are: called from an effect, it
1400
+ // mustn't make the effect depend on the options or on the query.
1401
+ refetch: () => untracked(() => {
1402
+ const options = defaultedOptions();
1403
+ resolve(options.queryKey).fetch(options, { cancelRefetch: true });
1404
+ }),
1249
1405
  },
1250
1406
  destroy: () => refs.forEach((ref) => ref.destroy()),
1251
1407
  };