@ethlete/query 6.0.0-next.10 → 6.0.0-next.11

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,5 +1,13 @@
1
1
  # @ethlete/query
2
2
 
3
+ ## 6.0.0-next.11
4
+
5
+ ### Patch Changes
6
+
7
+ - [#2995](https://github.com/ethlete-io/ethdk/pull/2995) [`9038da9`](https://github.com/ethlete-io/ethdk/commit/9038da9dba1af6597ce24056f45354b4073673aa) Thanks [@github-actions](https://github.com/apps/github-actions)! - Fix `withSuccessHandling`, `withErrorHandling` and `withLogging` callbacks sometimes being skipped when a query re-executes in quick succession (e.g. polling, auto-refresh, or fast repeated requests). Each execution's result now reliably triggers its handler.
8
+
9
+ - [#2995](https://github.com/ethlete-io/ethdk/pull/2995) [`24b4fd7`](https://github.com/ethlete-io/ethdk/commit/24b4fd7e1aebda8a4844d4a8ec9700aa1a89a0ee) Thanks [@github-actions](https://github.com/apps/github-actions)! - Fix secure queries intermittently throwing `tokensNotAvailableInsideAuthAndExec` when the auth query completes on a different reactive timeline than the access token is set on (e.g. a cross-client / secure login query whose token is populated by a separate effect). The secure execute factory now waits for the access token to be available before running the request instead of assuming it is set the moment the auth query has a response, and recovers automatically once the token arrives.
10
+
3
11
  ## 6.0.0-next.10
4
12
 
5
13
  ### Patch Changes
@@ -1,7 +1,7 @@
1
1
  import * as i0 from '@angular/core';
2
- import { isDevMode, DestroyRef, InjectionToken, inject, Injector, EnvironmentInjector, ErrorHandler, ɵEffectScheduler as _EffectScheduler, createEnvironmentInjector, effect, computed, untracked, signal, linkedSignal, runInInjectionContext, assertInInjectionContext, ChangeDetectorRef, ViewContainerRef, TemplateRef, Input, Directive, ElementRef, input, Pipe, ViewEncapsulation, ChangeDetectionStrategy, Component, DOCUMENT, NgZone } from '@angular/core';
2
+ import { isDevMode, DestroyRef, InjectionToken, inject, Injector, EnvironmentInjector, ErrorHandler, createEnvironmentInjector, effect, untracked, signal, linkedSignal, computed, runInInjectionContext, assertInInjectionContext, ChangeDetectorRef, ViewContainerRef, TemplateRef, Input, Directive, ElementRef, input, Pipe, ViewEncapsulation, ChangeDetectionStrategy, Component, DOCUMENT, NgZone } from '@angular/core';
3
3
  import { createStaticProvider, RuntimeError, clamp, createDestroy, computedTillTruthy, getCookie, setCookie, deleteCookie, createProvider, nextFrame, createRootProvider, isObject, injectRoute, getDomain, injectQueryParamChanges, injectQueryParams, clone, equal, ET_PROPERTY_REMOVED, previousSignalValue } from '@ethlete/core';
4
- import { Observable, Subscription, tap, retry, throwError, timer, catchError, takeUntil, startWith, pairwise, switchMap, of, BehaviorSubject, Subject, fromEvent, take, filter, ReplaySubject, map, shareReplay, interval, skip, takeWhile, combineLatest, EMPTY, withLatestFrom, debounceTime, merge, throttleTime } from 'rxjs';
4
+ import { filter, Subject, Subscription, Observable, tap, retry, throwError, timer, catchError, takeUntil, startWith, pairwise, switchMap, of, BehaviorSubject, fromEvent, take, ReplaySubject, map, shareReplay, interval, skip, takeWhile, combineLatest, EMPTY, withLatestFrom, debounceTime, merge, throttleTime } from 'rxjs';
5
5
  import { toObservable, takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
6
6
  import { HttpClient, HttpEventType, HttpErrorResponse, HttpHeaders, HttpStatusCode } from '@angular/common/http';
7
7
  import { getActiveConsumer, setActiveConsumer } from '@angular/core/primitives/signals';
@@ -46,9 +46,6 @@ const createGqlExecuteFn = (executeOptions) => {
46
46
  const reset = () => resetExecuteState({ executeState, executeOptions });
47
47
  const exec = (executeArgs) => {
48
48
  circularChecker.check();
49
- // Make sure all effects are flushed before reading the args.
50
- // A withArgs feature effect might still be pending otherwise resulting in the wrong args being read.
51
- executeOptions.deps.effectScheduler.flush();
52
49
  const { args = executeOptions.state.args(), options } = executeArgs ?? {};
53
50
  const query = transformGql(executeOptions.creatorInternals.query);
54
51
  let gqlParams = query(args?.variables);
@@ -133,7 +130,6 @@ const setupQueryDependencies = (options) => {
133
130
  scopeDestroyRef: hostInjector.get(DestroyRef),
134
131
  client: hostInjector.get(clientToken),
135
132
  injector: undefined, // Will be set after injector creation
136
- effectScheduler: hostInjector.get(_EffectScheduler),
137
133
  ngErrorHandler: hostInjector.get(ErrorHandler),
138
134
  httpClient: hostInjector.get(HttpClient),
139
135
  };
@@ -307,17 +303,27 @@ const withArgs = (args) => {
307
303
  return createQueryFeature({
308
304
  type: QueryFeatureType.WITH_ARGS,
309
305
  fn: (context) => {
310
- const currArgs = computed(() => args(), ...(ngDevMode ? [{ debugName: "currArgs" }] : /* istanbul ignore next */ []));
306
+ // `state.args` is reactively backed by this source, so every reader (exec, polling,
307
+ // auto-refresh) always sees the latest value with no pending-effect window. Sentinels are
308
+ // resolved here: CLEAR -> null, `null` ("don't change") -> keep the previous value.
309
+ let previous = null;
310
+ context.state.subtle.setArgsSource(() => {
311
+ const value = args();
312
+ if (value === CLEAR_QUERY_ARGS) {
313
+ previous = null;
314
+ return null;
315
+ }
316
+ if (value === null)
317
+ return previous;
318
+ previous = value;
319
+ return value;
320
+ });
321
+ // Trigger (auto-)execution whenever the resolved args change.
311
322
  nestedEffect(() => {
312
- const currArgsNow = currArgs();
323
+ const currArgsNow = context.state.args();
313
324
  if (currArgsNow === null)
314
325
  return;
315
326
  untracked(() => {
316
- if (currArgsNow === CLEAR_QUERY_ARGS) {
317
- context.state.args.set(null);
318
- return;
319
- }
320
- context.state.args.set(currArgsNow);
321
327
  if (context.flags.shouldAutoExecute)
322
328
  context.execute({ args: currArgsNow });
323
329
  });
@@ -366,14 +372,9 @@ const withLogging = (options) => {
366
372
  return createQueryFeature({
367
373
  type: QueryFeatureType.WITH_LOGGING,
368
374
  fn: (context) => {
369
- nestedEffect(() => {
370
- const event = context.state.latestHttpEvent();
371
- if (event === null)
372
- return;
373
- untracked(() => {
374
- options.logFn(event);
375
- });
376
- }, { injector: context.deps.injector });
375
+ context.state.events$.pipe(takeUntilDestroyed(context.deps.destroyRef)).subscribe((event) => {
376
+ options.logFn(event);
377
+ });
377
378
  },
378
379
  });
379
380
  };
@@ -382,14 +383,11 @@ const withErrorHandling = (options) => {
382
383
  return createQueryFeature({
383
384
  type: QueryFeatureType.WITH_ERROR_HANDLING,
384
385
  fn: (context) => {
385
- nestedEffect(() => {
386
- const error = context.state.error();
387
- if (error === null)
388
- return;
389
- untracked(() => {
390
- options.handler(error);
391
- });
392
- }, { injector: context.deps.injector });
386
+ context.state.events$
387
+ .pipe(filter((event) => event.type === 'error'), takeUntilDestroyed(context.deps.destroyRef))
388
+ .subscribe((event) => {
389
+ options.handler(event.error);
390
+ });
393
391
  },
394
392
  });
395
393
  };
@@ -398,14 +396,11 @@ const withSuccessHandling = (options) => {
398
396
  return createQueryFeature({
399
397
  type: QueryFeatureType.WITH_SUCCESS_HANDLING,
400
398
  fn: (context) => {
401
- nestedEffect(() => {
402
- const event = context.state.latestHttpEvent();
403
- if (!event || event.type !== HttpEventType.Response)
404
- return;
405
- untracked(() => {
406
- options.handler(context.state.response());
407
- });
408
- }, { injector: context.deps.injector });
399
+ context.state.events$
400
+ .pipe(filter((event) => event.type === HttpEventType.Response), takeUntilDestroyed(context.deps.destroyRef))
401
+ .subscribe(() => {
402
+ options.handler(context.state.response());
403
+ });
409
404
  },
410
405
  });
411
406
  };
@@ -469,9 +464,21 @@ const setupQueryState = (options) => {
469
464
  const loading = linkedSignal(() => request()?.loading() ?? null, ...(ngDevMode ? [{ debugName: "loading" }] : /* istanbul ignore next */ []));
470
465
  const error = linkedSignal(() => request()?.error() ?? null, ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
471
466
  const latestHttpEvent = linkedSignal(() => request()?.currentEvent() ?? null, ...(ngDevMode ? [{ debugName: "latestHttpEvent" }] : /* istanbul ignore next */ []));
472
- const args = signal(null, ...(ngDevMode ? [{ debugName: "args" }] : /* istanbul ignore next */ []));
467
+ // `args` is reactively sourced: reads always reflect the latest value from the installed source
468
+ // (e.g. the `withArgs` feature), so there is no stale window between a source change and an
469
+ // effect writing it. It stays writable (reset / auth-error clear it via `.set(null)`); a manual
470
+ // set is overridden again the next time the source's dependencies change.
471
+ const argsSource = signal(() => null, ...(ngDevMode ? [{ debugName: "argsSource" }] : /* istanbul ignore next */ []));
472
+ const args = linkedSignal(() => argsSource()(), ...(ngDevMode ? [{ debugName: "args" }] : /* istanbul ignore next */ []));
473
+ const setArgsSource = (source) => argsSource.set(source);
473
474
  const lastTimeExecutedAt = signal(null, ...(ngDevMode ? [{ debugName: "lastTimeExecutedAt" }] : /* istanbul ignore next */ []));
474
475
  const lastTriggeredBy = signal(null, ...(ngDevMode ? [{ debugName: "lastTriggeredBy" }] : /* istanbul ignore next */ []));
476
+ const requestEvents$ = new Subject();
477
+ let requestEventsSubscription = Subscription.EMPTY;
478
+ const bindRequestEvents = (request) => {
479
+ requestEventsSubscription.unsubscribe();
480
+ requestEventsSubscription = request.events$.subscribe((event) => requestEvents$.next(event));
481
+ };
475
482
  const executionState = computed(() => {
476
483
  const currentResponse = response();
477
484
  const currentError = error();
@@ -517,9 +524,12 @@ const setupQueryState = (options) => {
517
524
  lastTimeExecutedAt,
518
525
  lastTriggeredBy,
519
526
  executionState,
527
+ events$: requestEvents$.asObservable(),
520
528
  subtle: {
521
529
  request,
522
530
  rawResponse,
531
+ bindRequestEvents,
532
+ setArgsSource,
523
533
  },
524
534
  };
525
535
  return state;
@@ -746,6 +756,7 @@ const queryExecute = (options) => {
746
756
  state.lastTimeExecutedAt.set(Date.now());
747
757
  state.lastTriggeredBy.set(runQueryOptions?.triggeredBy ?? null);
748
758
  state.subtle.request.set(request);
759
+ state.subtle.bindRequestEvents(request);
749
760
  };
750
761
  const circularQueryDependencyChecker = () => {
751
762
  let lastTriggerTs = 0;
@@ -779,9 +790,6 @@ const createExecuteFn = (executeOptions) => {
779
790
  const reset = () => resetExecuteState({ executeState, executeOptions });
780
791
  const exec = (executeArgs) => {
781
792
  circularChecker.check();
782
- // Make sure all effects are flushed before reading the args.
783
- // A withArgs feature effect might still be pending otherwise resulting in the wrong args being read.
784
- executeOptions.deps.effectScheduler.flush();
785
793
  const { args = executeOptions.state.args(), options } = executeArgs ?? {};
786
794
  queryExecute({ executeOptions, executeState, args, options });
787
795
  };
@@ -1540,6 +1548,7 @@ const createHttpRequest = (options) => {
1540
1548
  let currentStreamSubscription = Subscription.EMPTY;
1541
1549
  const { args, clientOptions, dependencies } = options;
1542
1550
  const currentEvent = signal(null, ...(ngDevMode ? [{ debugName: "currentEvent" }] : /* istanbul ignore next */ []));
1551
+ const event$ = new Subject();
1543
1552
  const loading = signal(null, ...(ngDevMode ? [{ debugName: "loading" }] : /* istanbul ignore next */ []));
1544
1553
  const error = signal(null, ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
1545
1554
  const response = signal(null, ...(ngDevMode ? [{ debugName: "response" }] : /* istanbul ignore next */ []));
@@ -1601,11 +1610,10 @@ const createHttpRequest = (options) => {
1601
1610
  return true;
1602
1611
  };
1603
1612
  const destroy = () => {
1604
- if (currentStreamSubscription.closed) {
1605
- return false;
1606
- }
1613
+ const wasActive = !currentStreamSubscription.closed;
1607
1614
  currentStreamSubscription.unsubscribe();
1608
- return true;
1615
+ event$.complete();
1616
+ return wasActive;
1609
1617
  };
1610
1618
  const updateState = (event) => {
1611
1619
  switch (event.type) {
@@ -1635,12 +1643,15 @@ const createHttpRequest = (options) => {
1635
1643
  break;
1636
1644
  }
1637
1645
  currentEvent.set(event);
1646
+ event$.next(event);
1638
1647
  };
1639
1648
  const updateErrorState = (errorResponse) => {
1640
1649
  const errorRes = createQueryErrorResponse(errorResponse);
1641
1650
  error.set(errorRes);
1642
1651
  loading.set(null);
1643
- currentEvent.set({ type: 'error', error: errorRes });
1652
+ const errorEvent = { type: 'error', error: errorRes };
1653
+ currentEvent.set(errorEvent);
1654
+ event$.next(errorEvent);
1644
1655
  options.dependencies.ngErrorHandler.handleError(errorRes.raw);
1645
1656
  };
1646
1657
  const updateLoadingState = (event) => {
@@ -1680,6 +1691,7 @@ const createHttpRequest = (options) => {
1680
1691
  error: error.asReadonly(),
1681
1692
  response: response.asReadonly(),
1682
1693
  currentEvent: currentEvent.asReadonly(),
1694
+ events$: event$.asObservable(),
1683
1695
  isStale,
1684
1696
  };
1685
1697
  return httpRequest;
@@ -2623,11 +2635,14 @@ const createSecureExecuteFactory = (options) => {
2623
2635
  const circularChecker = circularQueryDependencyChecker();
2624
2636
  let authQuerySubscription = Subscription.EMPTY;
2625
2637
  let tokenRefreshSubscription = Subscription.EMPTY;
2638
+ let tokenWaitSubscription = Subscription.EMPTY;
2626
2639
  const reset = () => {
2627
2640
  authQuerySubscription.unsubscribe();
2628
2641
  authQuerySubscription = Subscription.EMPTY;
2629
2642
  tokenRefreshSubscription.unsubscribe();
2630
2643
  tokenRefreshSubscription = Subscription.EMPTY;
2644
+ tokenWaitSubscription.unsubscribe();
2645
+ tokenWaitSubscription = Subscription.EMPTY;
2631
2646
  resetExecuteState({
2632
2647
  executeState,
2633
2648
  executeOptions: { deps: options.deps, state: options.state },
@@ -2668,11 +2683,27 @@ const createSecureExecuteFactory = (options) => {
2668
2683
  },
2669
2684
  }, tokens, headers, executeState);
2670
2685
  };
2686
+ // The auth query completing and the access token being populated happen on two
2687
+ // different reactive timelines: the token is set by a separate token-extraction
2688
+ // effect (see `setupBearerQueryRegistry`), and for cross-client / secure auth
2689
+ // queries the completion is delivered through several nested `toObservable` hops.
2690
+ // Calling `authAndExec` purely because the auth query is "done" therefore races
2691
+ // the token and can run while it is still null. Gate on the token signal instead:
2692
+ // execute immediately if it is already available, otherwise wait for it.
2693
+ const authAndExecWhenTokenReady = (executeArgs) => {
2694
+ if (options.authProvider.accessToken()) {
2695
+ authAndExec(executeArgs);
2696
+ return;
2697
+ }
2698
+ tokenWaitSubscription.unsubscribe();
2699
+ tokenWaitSubscription = toObservable(options.authProvider.accessToken, {
2700
+ injector: options.deps.injector,
2701
+ })
2702
+ .pipe(filter(Boolean), take(1), tap(() => authAndExec(executeArgs)))
2703
+ .subscribe();
2704
+ };
2671
2705
  const exec = (executeArgs) => {
2672
2706
  circularChecker.check();
2673
- // Make sure all effects are flushed before reading the args.
2674
- // A withArgs feature effect might still be pending otherwise resulting in the wrong args being read.
2675
- options.deps.effectScheduler.flush();
2676
2707
  const execArgsWithDefaults = {
2677
2708
  args: executeArgs?.args ?? options.state.args(),
2678
2709
  options: executeArgs?.options,
@@ -2681,6 +2712,8 @@ const createSecureExecuteFactory = (options) => {
2681
2712
  authQuerySubscription = Subscription.EMPTY;
2682
2713
  tokenRefreshSubscription.unsubscribe();
2683
2714
  tokenRefreshSubscription = Subscription.EMPTY;
2715
+ tokenWaitSubscription.unsubscribe();
2716
+ tokenWaitSubscription = Subscription.EMPTY;
2684
2717
  tokenRefreshSubscription = options.authProvider.afterTokenRefresh$
2685
2718
  .pipe(filter(() => {
2686
2719
  const currentError = options.state.error();
@@ -2708,14 +2741,14 @@ const createSecureExecuteFactory = (options) => {
2708
2741
  error(query);
2709
2742
  }
2710
2743
  else if (query.response()) {
2711
- authAndExec(execArgsWithDefaults);
2744
+ authAndExecWhenTokenReady(execArgsWithDefaults);
2712
2745
  }
2713
2746
  }));
2714
2747
  }))
2715
2748
  .subscribe();
2716
2749
  }
2717
2750
  else if (authQuery.response()) {
2718
- authAndExec(execArgsWithDefaults);
2751
+ authAndExecWhenTokenReady(execArgsWithDefaults);
2719
2752
  }
2720
2753
  else if (authQuery.error()) {
2721
2754
  error(authQuery);
@@ -5041,26 +5074,17 @@ const createQueryRepository = (config) => {
5041
5074
  else {
5042
5075
  const consumers = new Map([]);
5043
5076
  consumers.set(consumerDestroyRef, destroyListener);
5044
- const eventSubscription = combineLatest([
5045
- toObservable(request.error, { injector: config.dependencies.injector }),
5046
- toObservable(request.response, { injector: config.dependencies.injector }),
5047
- ]).subscribe(([errorValue, responseValue]) => {
5048
- if (errorValue?.raw instanceof HttpErrorResponse) {
5049
- eventsSubject.next({
5050
- type: 'request-error',
5051
- error: errorValue.raw,
5052
- key,
5053
- isSecure,
5054
- request,
5055
- });
5077
+ // Drive repository events off the request's discrete, terminal event stream rather than
5078
+ // recombining the `error` + `response` signals via combineLatest (which could observe stale
5079
+ // pairings). Each settle emits exactly one terminal event.
5080
+ const eventSubscription = request.events$.subscribe((event) => {
5081
+ if (event.type === 'error') {
5082
+ if (event.error.raw instanceof HttpErrorResponse) {
5083
+ eventsSubject.next({ type: 'request-error', error: event.error.raw, key, isSecure, request });
5084
+ }
5056
5085
  }
5057
- else if (responseValue !== null && !errorValue) {
5058
- eventsSubject.next({
5059
- type: 'request-success',
5060
- key,
5061
- isSecure,
5062
- request,
5063
- });
5086
+ else if (event.type === HttpEventType.Response) {
5087
+ eventsSubject.next({ type: 'request-success', key, isSecure, request });
5064
5088
  }
5065
5089
  });
5066
5090
  cache.set(key, {