ngx-signal-query 1.5.0-dev.2 → 1.5.0-dev.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.
@@ -232,6 +232,39 @@ function isValidTimeout(value) {
232
232
  function isPromiseLike(value) {
233
233
  return typeof value?.then === 'function';
234
234
  }
235
+ // Resolves once every promise has settled, and never rejects. It resolves
236
+ // with nothing: what the promises brought isn't the caller's to see.
237
+ /** @internal */
238
+ function whenAllSettled(promises) {
239
+ return Promise.allSettled(promises).then(() => undefined);
240
+ }
241
+ /** @internal */
242
+ function deferred() {
243
+ let resolve;
244
+ let reject;
245
+ const promise = new Promise((res, rej) => {
246
+ resolve = res;
247
+ reject = rej;
248
+ });
249
+ return { promise, resolve, reject };
250
+ }
251
+
252
+ /**
253
+ * The error a promise of a query's data rejects with when the fetch stops
254
+ * with no data to give: it was cancelled before the query had any
255
+ * (`cancelQueries()`, or the last component showing the query went away), or
256
+ * the query was removed (`removeQueries()`, e.g. on sign-out). The request
257
+ * didn't fail, so tell the two apart with `instanceof CancelledError`.
258
+ *
259
+ * A mutation's `mutateAsync()` rejects with one too, when the mutation cache
260
+ * is cleared while its run is in flight.
261
+ */
262
+ class CancelledError extends Error {
263
+ constructor(message = 'The query fetch was cancelled') {
264
+ super(message);
265
+ this.name = 'CancelledError';
266
+ }
267
+ }
235
268
 
236
269
  // Exponential backoff capped at 30s, matching TanStack's default.
237
270
  /** @internal */
@@ -254,28 +287,6 @@ function resolveRetryDelay(retryDelay, failureCount, error) {
254
287
  }
255
288
 
256
289
  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
- }
279
290
  /** @internal */
280
291
  class Query {
281
292
  key;
@@ -331,15 +342,17 @@ class Query {
331
342
  this.#scheduleGc();
332
343
  }
333
344
  }
334
- // How to refetch this query on its observers' behalf: the first observer
335
- // that wants it fetched decides, as in TanStack; null if none does.
336
- activeFetchOptions() {
345
+ // Refetches the query if a component shows it: the first observer that
346
+ // wants it fetched decides how, as in TanStack. A fetch in flight is
347
+ // replaced, as it may predate the change the caller is after. Returns the
348
+ // fetch's promise, or undefined if no observer wants the query fetched.
349
+ refetchIfActive() {
337
350
  for (const observer of this.#observers) {
338
351
  const options = observer.fetchOptions();
339
352
  if (options)
340
- return options;
353
+ return this.fetch(options, { cancelRefetch: true });
341
354
  }
342
- return null;
355
+ return;
343
356
  }
344
357
  // Refetches the query now that the user is back on the page, if one of its
345
358
  // observers wants that: its refetchOnWindowFocus is 'always', or true while
@@ -612,6 +625,9 @@ class Mutation {
612
625
  // eslint-disable-next-line @typescript-eslint/member-ordering
613
626
  state = this.#state.asReadonly();
614
627
  #subscription = null;
628
+ // The promise of the run in flight and how to settle it; null once it has
629
+ // settled, or before the run.
630
+ #run = null;
615
631
  #observers = 0;
616
632
  #options;
617
633
  #cache;
@@ -638,8 +654,14 @@ class Mutation {
638
654
  this.#observers--;
639
655
  this.#disposeIfDone();
640
656
  }
641
- /** Runs the mutation once. `variables` are passed straight to `mutationFn`. */
657
+ /**
658
+ * Runs the mutation once. `variables` are passed straight to `mutationFn`.
659
+ * Returns a promise of the run, settled as the state is, once its last hook
660
+ * is done: with the data, or with the error.
661
+ */
642
662
  execute(variables) {
663
+ const run = deferred();
664
+ this.#run = run;
643
665
  this.#state.set({
644
666
  status: 'pending',
645
667
  data: undefined,
@@ -668,12 +690,24 @@ class Mutation {
668
690
  })), concatMap((outcome) => this.#runHooks(outcome, variables).pipe(endWith(outcome))))
669
691
  .subscribe((outcome) => {
670
692
  this.#state.update((state) => ({ ...state, ...outcome }));
693
+ this.#run = null;
671
694
  this.#disposeIfDone();
695
+ if (outcome.status === 'success') {
696
+ run.resolve(outcome.data);
697
+ }
698
+ else {
699
+ run.reject(outcome.error);
700
+ }
672
701
  });
702
+ return run.promise;
673
703
  }
704
+ // Whoever waits for the run hears that it was cancelled, so its promise
705
+ // doesn't hang.
674
706
  cancel() {
675
707
  this.#subscription?.unsubscribe();
676
708
  this.#subscription = null;
709
+ this.#run?.reject(new CancelledError('The mutation was cancelled'));
710
+ this.#run = null;
677
711
  }
678
712
  /** Sends the write, retrying as `retry` allows, and emits its data once. */
679
713
  #request(variables) {
@@ -865,27 +899,58 @@ class QueryClient {
865
899
  /**
866
900
  * Imperatively fetches and caches a query, unless fresh data already exists
867
901
  * (governed by `staleTime`). Prefer {@link injectQuery} in components; use
868
- * this to prefetch, from an event handler or an effect.
902
+ * this to prefetch, from an event handler or an effect, or to wait for the
903
+ * data, e.g. in a route resolver.
904
+ *
905
+ * Resolves with the data as the cache holds it: at once if it's fresh,
906
+ * otherwise once the fetch lands. A key already fetching is waited for,
907
+ * not fetched again, unless `cancelRefetch` is set. Rejects with the error
908
+ * if the fetch fails. A cancelled fetch resolves with the data the cache
909
+ * holds, or rejects with a {@link CancelledError} if it holds none; a
910
+ * removed query always rejects with one. Nothing has to wait for the
911
+ * promise: a prefetch that fails only leaves the query in `'error'`.
912
+ *
913
+ * Unlike a component's query, it doesn't retry a failure unless `retry` is
914
+ * set here or in {@link withDefaultOptions}, so whoever waits for the data
915
+ * hears of the failure at once.
869
916
  *
870
917
  * @param key - The query key to fetch and cache under.
871
918
  * @param queryFn - Function returning the data as an `Observable` or `Promise`.
872
919
  * @param options - Fetch tuning (`staleTime`, `retry`, `retryDelay`,
873
920
  * `structuralSharing`, `cancelRefetch`).
921
+ * @returns A promise of the data.
922
+ *
923
+ * @example
924
+ * ```ts
925
+ * export const todoResolver: ResolveFn<Todo> = (route) => {
926
+ * const http = inject(HttpClient)
927
+ * const id = Number(route.paramMap.get('id'))
928
+ *
929
+ * return injectQueryClient().fetchQuery(['todos', id], () =>
930
+ * http.get<Todo>(`/api/todos/${id}`),
931
+ * )
932
+ * }
933
+ * ```
874
934
  */
875
935
  fetchQuery(key, queryFn, options = {}) {
876
- untracked(() => {
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.
936
+ return untracked(() => {
937
+ // Resolved as a query's options are, so one set of defaults serves
938
+ // both, except that a failure isn't retried unless asked: whoever waits
939
+ // for the data hears of it at once. cancelRefetch is about this call,
940
+ // not an option of the query.
879
941
  const { cancelRefetch, ...queryOptions } = options;
880
942
  const defaulted = this.defaultQueryOptions({
881
943
  ...queryOptions,
882
944
  queryKey: key,
883
945
  queryFn,
946
+ retry: queryOptions.retry ??
947
+ this.#config.defaultOptions?.queries?.retry ??
948
+ false,
884
949
  });
885
950
  const query = this.#cache.getOrCreate(key);
886
- if (query.shouldFetch(defaulted.staleTime)) {
887
- query.fetch(defaulted, { cancelRefetch });
888
- }
951
+ return query.shouldFetch(defaulted.staleTime)
952
+ ? query.fetch(defaulted, { cancelRefetch })
953
+ : Promise.resolve(query.state().data);
889
954
  });
890
955
  }
891
956
  /**
@@ -936,11 +1001,16 @@ class QueryClient {
936
1001
  * Marks matching queries as stale and refetches the actively observed ones,
937
1002
  * once per query however many components observe it. The refetch starts
938
1003
  * right away and replaces a fetch already in flight, which may predate the
939
- * change; this method doesn't wait for it. A query with no enabled observer
940
- * keeps the stale mark and refetches as soon as one appears. Commonly called
941
- * after a mutation succeeds.
1004
+ * change. A query with no enabled observer keeps the stale mark and
1005
+ * refetches as soon as one appears. Commonly called after a mutation
1006
+ * succeeds.
1007
+ *
1008
+ * Resolves once those refetches have settled, so a mutation hook that
1009
+ * returns it keeps the mutation pending until the data is fresh. It never
1010
+ * rejects: a refetch that fails shows in its query's `error`.
942
1011
  *
943
1012
  * @param filters - Which queries to invalidate; omit to invalidate all.
1013
+ * `refetchType: 'none'` only marks them, and resolves at once.
944
1014
  *
945
1015
  * @example
946
1016
  * ```ts
@@ -948,21 +1018,36 @@ class QueryClient {
948
1018
  * ```
949
1019
  */
950
1020
  invalidateQueries(filters) {
951
- untracked(() => {
1021
+ return untracked(() => {
952
1022
  const queries = this.#cache.findAll(filters);
953
1023
  queries.forEach((query) => query.invalidate());
954
- // Refetch each active query once, however many observers watch it; an
955
- // inactive one refetches when next observed, as shouldFetch sees the
956
- // flag. cancelRefetch: a fetch already in flight started before the
957
- // invalidation, so it may have missed the change.
958
- queries.forEach((query) => {
959
- const options = query.activeFetchOptions();
960
- if (options) {
961
- query.fetch(options, { cancelRefetch: true });
962
- }
963
- });
1024
+ // An inactive query refetches when next observed, as shouldFetch sees
1025
+ // the flag.
1026
+ return filters?.refetchType === 'none'
1027
+ ? Promise.resolve()
1028
+ : whenAllSettled(queries.map((query) => query.refetchIfActive()));
964
1029
  });
965
1030
  }
1031
+ /**
1032
+ * Refetches the matching queries that a component shows, whether their
1033
+ * data is stale or not, once per query however many components show it. A
1034
+ * fetch already in flight is replaced. Queries nobody shows are left as
1035
+ * they are, not even marked stale: to have them refetch once they're shown,
1036
+ * invalidate them with `invalidateQueries()`.
1037
+ *
1038
+ * Resolves once the refetches have settled. It never rejects: a refetch
1039
+ * that fails shows in its query's `error`.
1040
+ *
1041
+ * @param filters - Which queries to refetch; omit to refetch all.
1042
+ *
1043
+ * @example
1044
+ * ```ts
1045
+ * await client.refetchQueries({ queryKey: ['todos'] })
1046
+ * ```
1047
+ */
1048
+ refetchQueries(filters) {
1049
+ return untracked(() => whenAllSettled(this.#cache.findAll(filters).map((query) => query.refetchIfActive())));
1050
+ }
966
1051
  /**
967
1052
  * Cancels any in-flight fetches for matching queries.
968
1053
  *
@@ -1379,30 +1464,37 @@ function createQueryObserver(optionsFn, injector) {
1379
1464
  }
1380
1465
  return { data: state.data, status: state.status, isPlaceholderData: false };
1381
1466
  }, ...(ngDevMode ? [{ debugName: "resolved" }] : []));
1467
+ const result = {
1468
+ data: computed(() => resolved().data),
1469
+ status: computed(() => resolved().status),
1470
+ error: computed(() => query().state().error),
1471
+ isFetching: computed(() => query().state().isFetching),
1472
+ // First fetch in-flight: fetching with no resolved data yet. An active
1473
+ // placeholder counts as data — the UI shows it, not a loading state.
1474
+ isLoading: computed(() => query().state().isFetching && resolved().status === 'pending'),
1475
+ isPending: computed(() => resolved().status === 'pending'),
1476
+ isSuccess: computed(() => resolved().status === 'success'),
1477
+ isError: computed(() => resolved().status === 'error'),
1478
+ isPlaceholderData: computed(() => resolved().isPlaceholderData),
1479
+ failureCount: computed(() => query().state().failureCount),
1480
+ failureReason: computed(() => query().state().failureReason),
1481
+ // Force a fresh fetch regardless of staleTime and enabled, cancelling
1482
+ // any in-flight request (explicit user intent — get current data).
1483
+ // Untracked, as the client's methods are: called from an effect, it
1484
+ // mustn't make the effect depend on the options or on the query. However
1485
+ // the fetch ends, the signals show it, so the promise resolves with their
1486
+ // data and error and never rejects.
1487
+ refetch: () => untracked(() => {
1488
+ const options = defaultedOptions();
1489
+ return resolve(options.queryKey).fetch(options, {
1490
+ cancelRefetch: true,
1491
+ });
1492
+ })
1493
+ .catch(() => undefined)
1494
+ .then(() => ({ data: result.data(), error: result.error() })),
1495
+ };
1382
1496
  return {
1383
- result: {
1384
- data: computed(() => resolved().data),
1385
- status: computed(() => resolved().status),
1386
- error: computed(() => query().state().error),
1387
- isFetching: computed(() => query().state().isFetching),
1388
- // First fetch in-flight: fetching with no resolved data yet. An active
1389
- // placeholder counts as data — the UI shows it, not a loading state.
1390
- isLoading: computed(() => query().state().isFetching && resolved().status === 'pending'),
1391
- isPending: computed(() => resolved().status === 'pending'),
1392
- isSuccess: computed(() => resolved().status === 'success'),
1393
- isError: computed(() => resolved().status === 'error'),
1394
- isPlaceholderData: computed(() => resolved().isPlaceholderData),
1395
- failureCount: computed(() => query().state().failureCount),
1396
- failureReason: computed(() => query().state().failureReason),
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
- }),
1405
- },
1497
+ result,
1406
1498
  destroy: () => refs.forEach((ref) => ref.destroy()),
1407
1499
  };
1408
1500
  }
@@ -1578,9 +1670,11 @@ function injectQueries(optionsFn, options) {
1578
1670
  * until it settles. Mutations do not retry by default (a retried write is not
1579
1671
  * idempotent); opt in via `options.retry`. Whatever of `retry`, `retryDelay`
1580
1672
  * and the `onSuccess` / `onError` / `onSettled` hooks a mutation leaves unset
1581
- * comes from {@link withDefaultOptions}. `mutate()` and `reset()` are safe
1582
- * to call from an effect: neither makes the effect depend on what it reads,
1583
- * such as the options or the previous run. Bound to the current injection
1673
+ * comes from {@link withDefaultOptions}. `mutateAsync(variables)` runs it
1674
+ * the same way and returns a promise of the run's data. `mutate()`,
1675
+ * `mutateAsync()` and `reset()` are safe to call from an effect: none makes
1676
+ * the effect depend on what it reads, such as the options or the previous
1677
+ * run. Bound to the current injection
1584
1678
  * context. Destroying it doesn't cancel a run in flight: the write has most
1585
1679
  * likely reached the server, so the run finishes and fires its hooks.
1586
1680
  *
@@ -1592,8 +1686,8 @@ function injectQueries(optionsFn, options) {
1592
1686
  * @typeParam TContext - Type returned by `onMutate`, passed to later hooks.
1593
1687
  * @param optionsFn - Factory returning the {@link MutationOptions}.
1594
1688
  * @param options - Optional `injector` to use outside an injection context.
1595
- * @returns A {@link MutationResult}: `mutate()`, `reset()`, and state signals
1596
- * (`status`, `data`, `error`, `isPending`, …).
1689
+ * @returns A {@link MutationResult}: `mutate()`, `mutateAsync()`, `reset()`,
1690
+ * and state signals (`status`, `data`, `error`, `isPending`, …).
1597
1691
  *
1598
1692
  * @example
1599
1693
  * ```ts
@@ -1625,23 +1719,32 @@ function injectMutation(optionsFn, options) {
1625
1719
  inject(DestroyRef).onDestroy(() => untracked(current)?.removeObserver());
1626
1720
  const state = computed(() => current()?.state() ??
1627
1721
  getInitialState(), ...(ngDevMode ? [{ debugName: "state" }] : []));
1722
+ // mutate(), mutateAsync() and reset() run untracked, as the QueryClient's
1723
+ // methods do. A call made from an effect mustn't make the effect depend on
1724
+ // what they read to do their job: the previous run's state, the options,
1725
+ // or what onMutate and mutationFn read. Otherwise a change to any of them
1726
+ // re-runs the effect, and the effect repeats the call.
1727
+ //
1728
+ // Starts a run for mutate() and mutateAsync(), makes it the run the
1729
+ // signals below follow, and returns its promise.
1730
+ const start = (variables) => untracked(() => {
1731
+ // Stop observing the previous run. It isn't cancelled — if it's
1732
+ // still in flight it finishes and fires its hooks; it just no longer
1733
+ // feeds the signals below.
1734
+ current()?.removeObserver();
1735
+ // Options are read per call, so a signal used in them stays live.
1736
+ const run = cache.build(client.defaultMutationOptions(optionsFn()));
1737
+ run.addObserver();
1738
+ current.set(run);
1739
+ return run.execute(variables);
1740
+ });
1628
1741
  return {
1629
- // mutate() and reset() run untracked, as the QueryClient's methods do. A
1630
- // call made from an effect mustn't make the effect depend on what they
1631
- // read to do their job: the previous run's state, the options, or what
1632
- // onMutate and mutationFn read. Otherwise a change to any of them
1633
- // re-runs the effect, and the effect repeats the call.
1634
- mutate: (variables) => untracked(() => {
1635
- // Stop observing the previous run. It isn't cancelled — if it's
1636
- // still in flight it finishes and fires its hooks; it just no longer
1637
- // feeds the signals below.
1638
- current()?.removeObserver();
1639
- // Options are read per call, so a signal used in them stays live.
1640
- const run = cache.build(client.defaultMutationOptions(optionsFn()));
1641
- run.addObserver();
1642
- current.set(run);
1643
- run.execute(variables);
1644
- }),
1742
+ // The outcome shows in the signals; a failure mustn't surface as an
1743
+ // unhandled rejection too.
1744
+ mutate: (variables) => {
1745
+ start(variables).catch(() => undefined);
1746
+ },
1747
+ mutateAsync: start,
1645
1748
  reset: () => untracked(() => {
1646
1749
  // "Forget the result", not "stop the request". A run still in
1647
1750
  // flight keeps going and fires its hooks, so the cache stays in step
@@ -1834,5 +1937,5 @@ function withDefaultOptions(options) {
1834
1937
  * Generated bundle index. Do not edit.
1835
1938
  */
1836
1939
 
1837
- export { QueryClient, QueryClientFeatureKind, injectIsFetching, injectIsMutating, injectMutation, injectQueries, injectQuery, injectQueryClient, keepPreviousData, mutationOptions, provideQueryClient, queryOptions, withDefaultOptions };
1940
+ export { CancelledError, QueryClient, QueryClientFeatureKind, injectIsFetching, injectIsMutating, injectMutation, injectQueries, injectQuery, injectQueryClient, keepPreviousData, mutationOptions, provideQueryClient, queryOptions, withDefaultOptions };
1838
1941
  //# sourceMappingURL=ngx-signal-query.mjs.map