@ethlete/query 6.0.0-next.38 → 6.0.0-next.39

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.
@@ -2,7 +2,7 @@ import * as i0 from '@angular/core';
2
2
  import { inject, computed, DestroyRef, InjectionToken, Injector, EnvironmentInjector, ElementRef, ErrorHandler, createEnvironmentInjector, effect, untracked, signal, linkedSignal, runInInjectionContext, isDevMode, PLATFORM_ID, resource, DOCUMENT, VERSION, makeEnvironmentProviders, assertInInjectionContext, provideEnvironmentInitializer, ChangeDetectorRef, ViewContainerRef, TemplateRef, Input, Directive, input, Pipe, ViewEncapsulation, Component, NgZone } from '@angular/core';
3
3
  import { toObservable, takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
4
4
  import { UrlTree, Router, RedirectCommand, ActivatedRoute } from '@angular/router';
5
- import { filter, take, map, defer, from, Subject, Subscription, throwError, of, timer, switchMap, tap, retry, catchError, EMPTY, takeUntil, finalize, mergeMap, toArray, merge, takeWhile, firstValueFrom, concatMap, fromEvent, throttleTime, interval, combineLatest, Observable, startWith, pairwise, BehaviorSubject, ReplaySubject, shareReplay, skip, withLatestFrom, debounceTime } from 'rxjs';
5
+ import { filter, take, map, defer, from, Subject, Subscription, throwError, of, timer, switchMap, tap, retry, catchError, EMPTY, takeUntil, finalize, mergeMap, toArray, merge, takeWhile, firstValueFrom, race, concatMap, fromEvent, throttleTime, interval, combineLatest, Observable, startWith, pairwise, BehaviorSubject, ReplaySubject, shareReplay, skip, withLatestFrom, debounceTime } from 'rxjs';
6
6
  import { defineStaticProvider, RuntimeError, clamp, randomId, defineRootProvider, isObject, injectUnsavedChangesCoordinator, injectRoute, getCookie, getDomain, deleteCookie, setCookie, CORE_VERSION, createDestroy, computedTillTruthy, defineProvider, toProvideFn, toInjectFn, nextFrame, injectQueryParams, injectQueryParamChanges, clone, equal, ET_PROPERTY_REMOVED, previousSignalValue } from '@ethlete/core';
7
7
  import { HttpClient, HttpEventType, HttpErrorResponse, HttpResponse, HttpHeaders, HttpStatusCode } from '@angular/common/http';
8
8
  import { getActiveConsumer, setActiveConsumer } from '@angular/core/primitives/signals';
@@ -1001,12 +1001,16 @@ const setupQueryState = (options) => {
1001
1001
  requestEventsSubscription.unsubscribe();
1002
1002
  requestEventsSubscription = request.events$.subscribe((event) => requestEvents$.next(event));
1003
1003
  };
1004
+ options.destroyRef?.onDestroy(() => {
1005
+ requestEventsSubscription.unsubscribe();
1006
+ requestEvents$.complete();
1007
+ });
1004
1008
  const executionState = computed(() => {
1005
1009
  const currentResponse = response();
1006
1010
  const currentError = error();
1007
1011
  const currentLoading = loading();
1008
1012
  if (currentLoading) {
1009
- if (currentResponse) {
1013
+ if (currentResponse !== null) {
1010
1014
  return {
1011
1015
  type: 'loading',
1012
1016
  hasCachedResponse: true,
@@ -1020,10 +1024,19 @@ const setupQueryState = (options) => {
1020
1024
  loading: currentLoading,
1021
1025
  };
1022
1026
  }
1027
+ else if (currentError && currentResponse !== null) {
1028
+ return {
1029
+ type: 'failure',
1030
+ error: currentError,
1031
+ hasCachedResponse: true,
1032
+ cachedResponse: currentResponse,
1033
+ };
1034
+ }
1023
1035
  else if (currentError) {
1024
1036
  return {
1025
1037
  type: 'failure',
1026
1038
  error: currentError,
1039
+ hasCachedResponse: false,
1027
1040
  };
1028
1041
  }
1029
1042
  else if (currentResponse !== null || latestHttpEvent()?.type === HttpEventType.Response) {
@@ -1239,6 +1252,7 @@ const createBaseQuery = (options) => {
1239
1252
  const devtoolsOverrides = createQueryDevtoolsOverridesRecorder();
1240
1253
  const state = setupQueryState({
1241
1254
  transformResponse: options.creator?.transformResponse,
1255
+ destroyRef: deps.destroyRef,
1242
1256
  devtoolsStats,
1243
1257
  devtoolsFormLinks,
1244
1258
  devtoolsOverrides,
@@ -3504,13 +3518,14 @@ const createQueryKeyLockManager = (namespace) => {
3504
3518
  }
3505
3519
  keyStates.set(states);
3506
3520
  };
3507
- const hold = (key) => {
3521
+ const hold = (key, options = {}) => {
3508
3522
  const isHolder = signal(false, /* @ts-ignore */
3509
3523
  ...(ngDevMode ? [{ debugName: "isHolder" }] : /* istanbul ignore next */ []));
3510
3524
  const abortController = new AbortController();
3511
3525
  const activeHold = { key, isHolder: isHolder.asReadonly() };
3512
3526
  let releaseHeldLock = null;
3513
3527
  let isReleased = false;
3528
+ let wasGranted = false;
3514
3529
  activeHolds.add(activeHold);
3515
3530
  publishKeyStates();
3516
3531
  const release = () => {
@@ -3530,12 +3545,16 @@ const createQueryKeyLockManager = (namespace) => {
3530
3545
  abortController.abort();
3531
3546
  }
3532
3547
  };
3548
+ // A stealing request is granted on the spot, so it never queues and has nothing to abort - and
3549
+ // the platform rejects `signal` together with `steal` outright.
3550
+ const requestOptions = options.steal ? { steal: true } : { signal: abortController.signal };
3533
3551
  locks
3534
- .request(`${namespace}:${key}`, { signal: abortController.signal }, () => {
3552
+ .request(`${namespace}:${key}`, requestOptions, () => {
3535
3553
  // Aborting a request the platform has already granted does nothing, so a release that raced
3536
3554
  // the grant lands here instead: take the lock and give it straight back.
3537
3555
  if (isReleased)
3538
3556
  return Promise.resolve();
3557
+ wasGranted = true;
3539
3558
  isHolder.set(true);
3540
3559
  publishKeyStates();
3541
3560
  return new Promise((resolve) => {
@@ -3547,6 +3566,13 @@ const createQueryKeyLockManager = (namespace) => {
3547
3566
  // standby hold ends. Nothing to recover from in either case - this tab is simply not the
3548
3567
  // holder, which is what the signal already says.
3549
3568
  isHolder.set(false);
3569
+ // A hold that had the lock only ends in a rejection one way: another tab stole it. This one
3570
+ // is over, so it leaves the published states behind and lets the caller decide what comes next.
3571
+ if (!wasGranted || isReleased)
3572
+ return;
3573
+ activeHolds.delete(activeHold);
3574
+ publishKeyStates();
3575
+ options.onLost?.();
3550
3576
  });
3551
3577
  return { isHolder: activeHold.isHolder, release };
3552
3578
  };
@@ -5323,8 +5349,35 @@ const rescheduleDelayMs = 5000;
5323
5349
  * leadership over mid-flight simply never acts on it.
5324
5350
  */
5325
5351
  const delegatedRefreshRetryMs = 3000;
5326
- /** How often a delegated refresh is re-asked for before the tab leaves it to the next 401. */
5352
+ /** How often a delegated refresh is re-asked for before this tab refreshes the tokens itself. */
5327
5353
  const maxDelegatedRefreshAttempts = 3;
5354
+ /**
5355
+ * The same budget for a leader that answered with "a refresh started here". It is working on the
5356
+ * refresh, so it gets longer than a silent one - but not forever, because the answer says a refresh
5357
+ * started, not that it will ever finish.
5358
+ */
5359
+ const maxDelegatedRefreshWindows = 6;
5360
+ /**
5361
+ * How long before its expiry an access token counts as stale, which is the point a follower stops
5362
+ * waiting for the leader. Long enough to run the delegation ladder before the token is worthless,
5363
+ * short enough that a leader whose timer is merely throttled still gets to act first.
5364
+ */
5365
+ const followerTakeoverGraceMs = 30000;
5366
+ /**
5367
+ * How long a tab that took a refresh over holds the refresh lock at most. It is released as soon as
5368
+ * the refresh reports back; this only bounds the wait for one that never does.
5369
+ */
5370
+ const takeoverRefreshMaxLockHoldMs = 15000;
5371
+ /**
5372
+ * The longest delay `setTimeout` can hold. Anything above it fires straight away, which would turn a
5373
+ * long wait into a busy loop that spends every re-arm at once.
5374
+ */
5375
+ const maxTimerDelayMs = 2147483647;
5376
+ /**
5377
+ * The lock a tab must hold to spend the refresh token outside the leadership. Two followers go stale
5378
+ * at the same instant - their access token is the same one - so the takeover needs a mutex of its own.
5379
+ */
5380
+ const refreshLockName = (providerName) => `ethlete-auth:refresh:${providerName}`;
5328
5381
  /**
5329
5382
  * How often a scheduled refresh may re-arm before it waits for a new token pair. A tab that keeps
5330
5383
  * declining is either not the leader or busy issuing tokens; either way a 401 still forces a refresh.
@@ -5383,18 +5436,20 @@ const withRefreshQuery = (key, config) => {
5383
5436
  let unauthorizedRefreshStreak = 0;
5384
5437
  let delegatedRefreshAttempts = 0;
5385
5438
  let isWatchingDelegatedRefresh = false;
5439
+ let hasLeaderAnsweredDelegation = false;
5386
5440
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
5387
5441
  const refreshQuery = () => context.queries[key];
5388
5442
  const executeRefresh = (reason) => {
5389
5443
  const currentRefreshToken = context.refreshToken();
5390
5444
  if (!currentRefreshToken)
5391
5445
  return 'noToken';
5392
- if (!context.isLeader()) {
5393
- // A scheduled tick on a follower is pure duplicate: multi-tab sync keeps the access token
5394
- // identical across tabs, so the leader's own timer is due at the same instant and it is the
5395
- // only tab allowed to act. Delegation is for the 401 path, where this tab has a request that
5396
- // really failed and the leader has no way of knowing.
5397
- if (reason === 'scheduled')
5446
+ if (reason !== 'takeover' && !context.isLeader()) {
5447
+ // A scheduled tick on a follower is pure duplicate while the token still has life left: multi-tab
5448
+ // sync keeps the access token identical across tabs, so the leader's own timer is due at the same
5449
+ // instant and it is the only tab allowed to act. Once the token is stale that reasoning is spent -
5450
+ // the leader had its moment and let it pass - so the tab delegates and, if that goes unanswered,
5451
+ // takes the refresh over.
5452
+ if (reason === 'scheduled' && !isAccessTokenStale())
5398
5453
  return 'notLeader';
5399
5454
  context.refreshCoordination?.request();
5400
5455
  watchDelegatedRefresh(context.accessToken());
@@ -5424,16 +5479,28 @@ const withRefreshQuery = (key, config) => {
5424
5479
  // the request has to go out.
5425
5480
  lastRefreshTime = now;
5426
5481
  refreshQuery().execute(buildArgs(currentRefreshToken), { triggeredBy: 'token-refresh' });
5482
+ // Answers every follower waiting on a delegated refresh, not just the one that asked: they all
5483
+ // hold the same access token, so they all go stale at the same moment.
5484
+ context.refreshCoordination?.announceStart();
5427
5485
  return 'executed';
5428
5486
  };
5429
5487
  /**
5430
- * Asks the leader again if a delegated refresh went unanswered - the request is a channel message
5431
- * with no ack, so a frozen leader, or one that handed the leadership over mid-flight, drops it
5432
- * silently. One watch at a time, and by the time it re-asks this tab may be the leader itself;
5433
- * `executeRefresh` re-decides that on every attempt.
5488
+ * Asks the leader again if a delegated refresh went unanswered, and refreshes the tokens in this tab
5489
+ * once the leader has run out of chances. A frozen leader keeps the lock that makes it the leader
5490
+ * while it runs no timer and reads no message, so waiting for it is waiting for nothing.
5491
+ *
5492
+ * One watch at a time, and by the time it re-asks this tab may be the leader itself; `executeRefresh`
5493
+ * re-decides that on every attempt.
5434
5494
  */
5435
5495
  function watchDelegatedRefresh(tokenAtRequest) {
5436
- if (isWatchingDelegatedRefresh || delegatedRefreshAttempts >= maxDelegatedRefreshAttempts)
5496
+ if (isWatchingDelegatedRefresh)
5497
+ return;
5498
+ // A new ladder judges the leader by what it answers this time round, not by an answer from an hour
5499
+ // ago - the tab that gave it may not even be open any more.
5500
+ if (delegatedRefreshAttempts === 0)
5501
+ hasLeaderAnsweredDelegation = false;
5502
+ const budget = hasLeaderAnsweredDelegation ? maxDelegatedRefreshWindows : maxDelegatedRefreshAttempts;
5503
+ if (delegatedRefreshAttempts >= budget)
5437
5504
  return;
5438
5505
  delegatedRefreshAttempts++;
5439
5506
  isWatchingDelegatedRefresh = true;
@@ -5444,11 +5511,62 @@ const withRefreshQuery = (key, config) => {
5444
5511
  // A new access token is the leader's answer - nothing to re-ask for.
5445
5512
  if (context.accessToken() !== tokenAtRequest) {
5446
5513
  delegatedRefreshAttempts = 0;
5514
+ hasLeaderAnsweredDelegation = false;
5515
+ return;
5516
+ }
5517
+ const spentBudget = hasLeaderAnsweredDelegation ? maxDelegatedRefreshWindows : maxDelegatedRefreshAttempts;
5518
+ if (delegatedRefreshAttempts >= spentBudget) {
5519
+ void takeOverRefresh(tokenAtRequest);
5447
5520
  return;
5448
5521
  }
5449
5522
  executeRefresh('unauthorized');
5450
5523
  });
5451
5524
  }
5525
+ /** Runs the refresh and keeps the refresh lock until it reports back, or until the bound runs out. */
5526
+ const runTakenOverRefresh = async () => {
5527
+ const attempt = executeRefresh('takeover');
5528
+ // The ladder starts over whatever came of it, including a takeover that could not run yet - a
5529
+ // refresh already in flight, or the floor under the 401-driven ones. Leaving the budget spent
5530
+ // would retire the only path a follower has to a token the leader is not going to issue.
5531
+ delegatedRefreshAttempts = 0;
5532
+ hasLeaderAnsweredDelegation = false;
5533
+ if (attempt !== 'executed')
5534
+ return;
5535
+ await new Promise((resolve) => {
5536
+ race(context.afterTokenRefresh$, timer(takeoverRefreshMaxLockHoldMs))
5537
+ .pipe(take(1), takeUntilDestroyed(destroyRef))
5538
+ .subscribe({ next: () => resolve(), complete: () => resolve() });
5539
+ });
5540
+ };
5541
+ /**
5542
+ * Spends the refresh token in this tab, after the leader failed to. Guarded by a lock of its own
5543
+ * rather than by the leadership, which the unresponsive tab still holds: every follower runs out of
5544
+ * patience at the same instant, and a single-use refresh token must not be spent twice.
5545
+ *
5546
+ * `ifAvailable` makes it a try-lock, so the tab that does not get the lock stands down instead of
5547
+ * queueing for a turn it must no longer take - the winner's new token pair arrives over the sync
5548
+ * channel long before then.
5549
+ */
5550
+ const takeOverRefresh = async (tokenAtRequest) => {
5551
+ if (context.accessToken() !== tokenAtRequest)
5552
+ return;
5553
+ const locks = typeof navigator === 'undefined' ? undefined : navigator.locks;
5554
+ if (!locks) {
5555
+ await runTakenOverRefresh();
5556
+ return;
5557
+ }
5558
+ await locks.request(refreshLockName(context.name), { ifAvailable: true }, async (lock) => {
5559
+ if (!lock) {
5560
+ // Another tab is spending the refresh token right now. Start the ladder over rather than race
5561
+ // it: this tab hears about the pair it issues, and needs a way back if it never issues one.
5562
+ delegatedRefreshAttempts = 0;
5563
+ return;
5564
+ }
5565
+ if (context.accessToken() !== tokenAtRequest)
5566
+ return;
5567
+ await runTakenOverRefresh();
5568
+ });
5569
+ };
5452
5570
  /**
5453
5571
  * How long a scheduled refresh that did not spend the refresh token waits before trying again.
5454
5572
  * `null` re-arms nothing, because only a new token pair can change the outcome - and that restarts
@@ -5463,11 +5581,14 @@ const withRefreshQuery = (key, config) => {
5463
5581
  return Math.max(minRefreshInterval - (Date.now() - lastRefreshTime), rescheduleDelayMs);
5464
5582
  case 'busy':
5465
5583
  return rescheduleDelayMs;
5466
- // Both wait for the leader: `delegated` asked it to act, `notLeader` left it to its own timer.
5467
- // Coming back keeps this tab covered if the leadership moves here in the meantime.
5468
5584
  case 'delegated':
5469
- case 'notLeader':
5470
5585
  return minRefreshInterval;
5586
+ // Left to the leader's own timer, which is due at the same instant as this one. Coming back at
5587
+ // the staleness deadline instead of on a fixed interval is what makes the wait bounded: a fixed
5588
+ // interval spends `maxRescheduleAttempts` within minutes of a token that can live for hours, and
5589
+ // the tab is then left with no armed timer at all.
5590
+ case 'notLeader':
5591
+ return Math.min(Math.max(msUntilAccessTokenIsStale() ?? minRefreshInterval, rescheduleDelayMs), maxTimerDelayMs);
5471
5592
  }
5472
5593
  };
5473
5594
  const calculateRefreshBuffer = (tokenLifetimeMs) => {
@@ -5480,8 +5601,8 @@ const withRefreshQuery = (key, config) => {
5480
5601
  const calculatedBuffer = tokenLifetimeMs * (1 - percentage);
5481
5602
  return Math.max(minBufferMs, Math.min(maxBufferMs, calculatedBuffer));
5482
5603
  };
5483
- /** When this access token is due to be refreshed, or `null` if it cannot be scheduled at all. */
5484
- const scheduledRefreshDelay = (token) => {
5604
+ /** When this token expires, as a timestamp, or `null` if the expiry cannot be read off it. */
5605
+ const tokenExpiresAt = (token) => {
5485
5606
  try {
5486
5607
  const decoded = context.bearerDecryptFn ? context.bearerDecryptFn(token) : decryptBearer(token);
5487
5608
  const bearerDataValue = patchQueryDevtoolsTokenPayload({
@@ -5496,29 +5617,61 @@ const withRefreshQuery = (key, config) => {
5496
5617
  }
5497
5618
  return null;
5498
5619
  }
5499
- const tokenLifetimeMs = expiresIn * 1000 - Date.now();
5500
- if (tokenLifetimeMs <= 0) {
5501
- if (!refreshIfExpired)
5502
- return null;
5503
- if (isDevMode()) {
5504
- console.warn('Token is already expired, triggering immediate refresh');
5505
- }
5506
- return 0;
5507
- }
5508
- return Math.max(tokenLifetimeMs - calculateRefreshBuffer(tokenLifetimeMs), 0);
5620
+ return expiresIn * 1000;
5509
5621
  }
5510
5622
  catch {
5511
5623
  return null;
5512
5624
  }
5513
5625
  };
5626
+ /** When this access token is due to be refreshed, or `null` if it cannot be scheduled at all. */
5627
+ const scheduledRefreshDelay = (token) => {
5628
+ const expiresAt = tokenExpiresAt(token);
5629
+ if (expiresAt === null)
5630
+ return null;
5631
+ const tokenLifetimeMs = expiresAt - Date.now();
5632
+ if (tokenLifetimeMs <= 0) {
5633
+ if (!refreshIfExpired)
5634
+ return null;
5635
+ if (isDevMode()) {
5636
+ console.warn('Token is already expired, triggering immediate refresh');
5637
+ }
5638
+ return 0;
5639
+ }
5640
+ return Math.max(tokenLifetimeMs - calculateRefreshBuffer(tokenLifetimeMs), 0);
5641
+ };
5642
+ /**
5643
+ * How long this tab can still work with the access token it holds, or `null` without a readable
5644
+ * expiry. Zero or less means a refresh has to happen now, whichever tab does it.
5645
+ */
5646
+ const msUntilAccessTokenIsStale = () => {
5647
+ const token = context.accessToken();
5648
+ if (!token)
5649
+ return null;
5650
+ const expiresAt = tokenExpiresAt(token);
5651
+ return expiresAt === null ? null : expiresAt - followerTakeoverGraceMs - Date.now();
5652
+ };
5653
+ const isAccessTokenStale = () => {
5654
+ const remainingMs = msUntilAccessTokenIsStale();
5655
+ return remainingMs !== null && remainingMs <= 0;
5656
+ };
5514
5657
  /**
5515
5658
  * The scheduled refresh, re-armed for as long as it keeps declining to spend the refresh token.
5516
5659
  * Without the re-arm a single declined attempt strands the session on a token nothing renews until
5517
5660
  * a request fails with a 401 - by which time the refresh token may be gone too.
5518
5661
  */
5519
5662
  const scheduledRefresh = (dueInMs, attemptsLeft) => (dueInMs <= 0 ? of(0) : timer(dueInMs)).pipe(concatMap(() => {
5520
- const retryInMs = retryScheduledIn(executeRefresh('scheduled'));
5521
- return retryInMs === null || attemptsLeft <= 0 ? EMPTY : scheduledRefresh(retryInMs, attemptsLeft - 1);
5663
+ const attempt = executeRefresh('scheduled');
5664
+ const retryInMs = retryScheduledIn(attempt);
5665
+ if (retryInMs === null)
5666
+ return EMPTY;
5667
+ // Waiting on another tab does not spend the budget. Both outcomes come back at a deadline
5668
+ // rather than on a fixed interval, and both end the moment a token pair arrives, because a
5669
+ // new token restarts the schedule. Spending the budget on them leaves a follower whose
5670
+ // leader never acts with no armed timer at all, which is the one state nothing recovers
5671
+ // from: the tab then holds a dead token and cannot even ask for a new one.
5672
+ if (attempt === 'notLeader' || attempt === 'delegated')
5673
+ return scheduledRefresh(retryInMs, attemptsLeft);
5674
+ return attemptsLeft <= 0 ? EMPTY : scheduledRefresh(retryInMs, attemptsLeft - 1);
5522
5675
  }));
5523
5676
  // A backgrounded tab's `timer()` does not fire on time - the browser throttles it, and a frozen
5524
5677
  // page stops running it altogether while keeping the Web Lock that makes it the leader. So the
@@ -5549,6 +5702,9 @@ const withRefreshQuery = (key, config) => {
5549
5702
  .pipe(switchMap(({ dueInMs }) => (dueInMs === null ? EMPTY : scheduledRefresh(dueInMs, maxRescheduleAttempts))), takeUntilDestroyed())
5550
5703
  .subscribe();
5551
5704
  context.refreshCoordination?.requests$.pipe(takeUntilDestroyed()).subscribe(() => executeRefresh('unauthorized'));
5705
+ context.refreshCoordination?.starts$.pipe(takeUntilDestroyed()).subscribe(() => {
5706
+ hasLeaderAnsweredDelegation = true;
5707
+ });
5552
5708
  const onRefreshFailure = config.onRefreshFailure ??
5553
5709
  (({ error, logout }) => {
5554
5710
  if (!retryableStatusCodes.includes(error.code))
@@ -5570,6 +5726,7 @@ const withRefreshQuery = (key, config) => {
5570
5726
  if (event.type === 'request-success' && event.isSecure) {
5571
5727
  unauthorizedRefreshStreak = 0;
5572
5728
  delegatedRefreshAttempts = 0;
5729
+ hasLeaderAnsweredDelegation = false;
5573
5730
  return;
5574
5731
  }
5575
5732
  if (event.type !== 'request-error' || !event.isSecure || event.error?.status !== 401)
@@ -5742,9 +5899,16 @@ const leaderLockName = (name) => `${LEADER_LOCK_NAMESPACE}:${leaderLockKey(name)
5742
5899
  /**
5743
5900
  * Web Locks has no "someone joined" event, so tabs announce themselves on this channel. Two messages
5744
5901
  * per tab lifetime, in place of the heartbeat this used to run once a second. It also carries a
5745
- * follower's request for a refresh, which is the other thing a tab can only say to the leader.
5902
+ * follower's request for a refresh and the leader's answer that one started, and the two messages the
5903
+ * leadership moves to a visible tab with.
5746
5904
  */
5747
5905
  const presenceChannelName = (name) => `ethlete-auth-leader:${name}`;
5906
+ /**
5907
+ * How long a visible tab waits for the leader to say it is still running before it takes the lock
5908
+ * over. Long enough for a same-origin channel message and its answer on a busy main thread, short
5909
+ * enough that the user does not sit in front of a session nothing can refresh.
5910
+ */
5911
+ const leaderClaimTimeoutMs = 1500;
5748
5912
  const countLeaderRequests = (snapshot, lockName) => {
5749
5913
  const isLeaderLock = (info) => info.name === lockName;
5750
5914
  // Held plus pending is exactly the tab count: every tab requests this one lock, one gets it and the
@@ -5755,9 +5919,22 @@ const countLeaderRequests = (snapshot, lockName) => {
5755
5919
  * Elects the one tab that refreshes the session's tokens, over the
5756
5920
  * [Web Locks API](https://developer.mozilla.org/docs/Web/API/Web_Locks_API).
5757
5921
  *
5758
- * The lock does the whole job: requests queue FIFO, so the tab that has been waiting longest takes
5759
- * over, and a holder that closes, crashes or navigates away has its lock released by the platform.
5760
- * No heartbeat, no timeout to tune, and no window in which two tabs both believe they are the leader.
5922
+ * The lock does most of the job: requests queue FIFO, so a tab that closes, crashes or navigates away
5923
+ * has its lock released by the platform and the next tab in line takes over. No heartbeat, no timeout
5924
+ * to tune, and no window in which two tabs both believe they are the leader.
5925
+ *
5926
+ * What the queue cannot decide is *which* tab should lead. Its order is the order the tabs opened in,
5927
+ * which says nothing about which of them is awake: a hidden tab has its timers throttled to about
5928
+ * once a minute and a frozen one runs none at all, so a session led from one is a session nothing
5929
+ * refreshes, while the tab the user is looking at sits queued behind it. So the leadership follows
5930
+ * the user instead:
5931
+ *
5932
+ * - A tab that becomes hidden while leading gives the lock up and asks again, which puts it behind
5933
+ * whoever else wants it - and hands it straight back if nobody does.
5934
+ * - A tab that becomes visible without leading claims the leadership, and every hidden tab gives way
5935
+ * to it. This is what brings the leadership back to a tab the user returns to.
5936
+ * - A leader that answers neither - it is frozen, or the machine it runs on is asleep - has the lock
5937
+ * taken off it, because nothing it does not run can hand it over.
5761
5938
  *
5762
5939
  * Degrades to "this tab is the leader" without Web Locks - the same single-tab behavior the previous
5763
5940
  * `localStorage` implementation fell back to, and the only safe default: a session that refreshes its
@@ -5767,10 +5944,20 @@ const setupLeaderElection = (options) => {
5767
5944
  const destroyRef = inject(DestroyRef);
5768
5945
  const lockName = leaderLockName(options.name);
5769
5946
  const lockManager = createQueryKeyLockManager(LEADER_LOCK_NAMESPACE);
5770
- const hold = lockManager.hold(leaderLockKey(options.name));
5771
5947
  const instanceCount = signal(1, /* @ts-ignore */
5772
5948
  ...(ngDevMode ? [{ debugName: "instanceCount" }] : /* istanbul ignore next */ []));
5773
5949
  const refreshRequests = new Subject();
5950
+ const refreshStarts = new Subject();
5951
+ let isDestroyed = false;
5952
+ let isParticipating = true;
5953
+ const requestLeadership = (requestOptions) => lockManager.hold(leaderLockKey(options.name), {
5954
+ steal: requestOptions?.steal,
5955
+ onLost: () => onLeadershipTaken(),
5956
+ });
5957
+ const hold = signal(requestLeadership(), /* @ts-ignore */
5958
+ ...(ngDevMode ? [{ debugName: "hold" }] : /* istanbul ignore next */ []));
5959
+ const isLeader = computed(() => hold().isHolder(), /* @ts-ignore */
5960
+ ...(ngDevMode ? [{ debugName: "isLeader" }] : /* istanbul ignore next */ []));
5774
5961
  const noop = () => {
5775
5962
  /* nothing was set up */
5776
5963
  };
@@ -5778,16 +5965,18 @@ const setupLeaderElection = (options) => {
5778
5965
  // and asks nobody else for a refresh.
5779
5966
  if (!lockManager.isSupported) {
5780
5967
  return {
5781
- isLeader: hold.isHolder,
5968
+ isLeader,
5782
5969
  instanceCount: instanceCount.asReadonly(),
5783
5970
  isSupported: false,
5784
5971
  requestRefresh: noop,
5785
5972
  refreshRequests$: refreshRequests.asObservable(),
5973
+ announceRefreshStart: noop,
5974
+ refreshStarts$: refreshStarts.asObservable(),
5786
5975
  cleanup: noop,
5787
5976
  };
5788
5977
  }
5789
5978
  const channel = typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel(presenceChannelName(options.name));
5790
- let isDestroyed = false;
5979
+ const isPageVisible = () => typeof document === 'undefined' || document.visibilityState === 'visible';
5791
5980
  const recount = async () => {
5792
5981
  const snapshot = await navigator.locks.query();
5793
5982
  if (isDestroyed)
@@ -5796,8 +5985,65 @@ const setupLeaderElection = (options) => {
5796
5985
  // count of zero would say the app is not running in the tab reading it.
5797
5986
  instanceCount.set(Math.max(countLeaderRequests(snapshot, lockName), 1));
5798
5987
  };
5799
- const announce = () => channel?.postMessage({ type: 'presence' });
5800
- const requestRefresh = () => channel?.postMessage({ type: 'refresh-requested' });
5988
+ const post = (message) => channel?.postMessage(message);
5989
+ const announce = () => post({ type: 'presence' });
5990
+ const requestRefresh = () => post({ type: 'refresh-requested' });
5991
+ const announceRefreshStart = () => post({ type: 'refresh-started' });
5992
+ /** Set while this tab has claimed the leadership and is waiting to hear whether the leader is alive. */
5993
+ let claimTimeout;
5994
+ const cancelClaim = () => {
5995
+ clearTimeout(claimTimeout);
5996
+ claimTimeout = undefined;
5997
+ };
5998
+ /** Asks for the lock again, which puts this tab behind every tab already waiting for it. */
5999
+ const requeue = (requestOptions) => {
6000
+ hold().release();
6001
+ hold.set(requestLeadership(requestOptions));
6002
+ };
6003
+ /**
6004
+ * Steps aside for the tabs that want the leadership. What a hidden tab does: its timers are
6005
+ * throttled to about once a minute, so the refresh it is responsible for happens roughly whenever.
6006
+ * It stays in the election, and gets the lock straight back if no other tab wants it.
6007
+ */
6008
+ const yieldLeadership = () => {
6009
+ if (isDestroyed || !isParticipating)
6010
+ return;
6011
+ requeue();
6012
+ void recount();
6013
+ };
6014
+ /**
6015
+ * Takes the lock off the tab holding it, once asking has not worked. The last resort of
6016
+ * {@link claimLeadership}: a frozen leader keeps its lock, answers nothing and refreshes nothing,
6017
+ * and a handover between hidden tabs can land the lock on the next hidden tab in the queue.
6018
+ */
6019
+ const takeLeadershipOver = () => {
6020
+ cancelClaim();
6021
+ if (isDestroyed || !isParticipating || isLeader())
6022
+ return;
6023
+ requeue({ steal: true });
6024
+ announce();
6025
+ void recount();
6026
+ };
6027
+ /**
6028
+ * Asks the tabs ahead of this one to give the leadership up, because the user is looking at this
6029
+ * one. Hidden tabs give way on their own; a leader that says nothing at all has the lock taken off
6030
+ * it once the wait runs out.
6031
+ */
6032
+ const claimLeadership = () => {
6033
+ if (isDestroyed || !isParticipating || !channel || isLeader() || !isPageVisible())
6034
+ return;
6035
+ if (claimTimeout !== undefined)
6036
+ return;
6037
+ claimTimeout = setTimeout(takeLeadershipOver, leaderClaimTimeoutMs);
6038
+ post({ type: 'claim' });
6039
+ };
6040
+ /** Rejoins the queue after another tab took the lock over, rather than leaving the election. */
6041
+ const onLeadershipTaken = () => {
6042
+ if (isDestroyed || !isParticipating)
6043
+ return;
6044
+ hold.set(requestLeadership());
6045
+ void recount();
6046
+ };
5801
6047
  if (channel) {
5802
6048
  channel.onmessage = (event) => {
5803
6049
  const message = event.data;
@@ -5805,39 +6051,134 @@ const setupLeaderElection = (options) => {
5805
6051
  void recount();
5806
6052
  return;
5807
6053
  }
6054
+ if (message?.type === 'refresh-started') {
6055
+ refreshStarts.next();
6056
+ return;
6057
+ }
6058
+ if (message?.type === 'claim') {
6059
+ // The answer goes out before the handover, and from the leader alone: it is the one thing that
6060
+ // tells the claiming tab that a tab which is not answering *is* a tab that is not running.
6061
+ if (isLeader())
6062
+ post({ type: 'leader-alive', isVisible: isPageVisible() });
6063
+ // Every hidden tab gives way, not only the leader: a claim that only moved the lock one place
6064
+ // along would land it on the next hidden tab in the queue.
6065
+ if (!isPageVisible())
6066
+ yieldLeadership();
6067
+ return;
6068
+ }
6069
+ if (message?.type === 'leader-alive') {
6070
+ // One visible tab leading is as good as another, so a claim against one is over. A hidden
6071
+ // leader is giving way instead, and the claim stays armed until it has: the release hands the
6072
+ // lock to the first tab in the queue, which may be another hidden one.
6073
+ if (message.isVisible)
6074
+ cancelClaim();
6075
+ return;
6076
+ }
5808
6077
  // Every tab hears the request; only the one that may spend the refresh token acts on it.
5809
- if (message?.type === 'refresh-requested' && hold.isHolder()) {
6078
+ if (message?.type === 'refresh-requested' && isLeader()) {
5810
6079
  refreshRequests.next();
5811
6080
  }
5812
6081
  };
5813
6082
  }
5814
6083
  effect(() => {
5815
- if (!hold.isHolder())
6084
+ if (!isLeader())
5816
6085
  return;
6086
+ cancelClaim();
5817
6087
  // Becoming the leader means the previous one went away. A tab that closed normally announced it
5818
6088
  // itself, one that crashed did not - so this is the moment to tell the others for free.
5819
6089
  announce();
5820
6090
  void recount();
5821
6091
  });
6092
+ /**
6093
+ * Leaves the election, because this tab is about to stop running. A frozen page keeps its lock while
6094
+ * its timers do not fire, so a leader that stays in would be a leader that refreshes nothing, and
6095
+ * every other tab would sit queued behind it until the user came back. The lock is not requested
6096
+ * again here: the platform would grant it to this page just as well while it is frozen.
6097
+ */
6098
+ const leaveElection = () => {
6099
+ if (isDestroyed || !isParticipating)
6100
+ return;
6101
+ isParticipating = false;
6102
+ cancelClaim();
6103
+ // Release before the goodbye: the tabs that recount on it should no longer see this one queued.
6104
+ hold().release();
6105
+ announce();
6106
+ };
6107
+ /** Takes part again, behind whichever tab took the leadership over in the meantime. */
6108
+ const rejoinElection = () => {
6109
+ if (isDestroyed || isParticipating)
6110
+ return;
6111
+ isParticipating = true;
6112
+ hold.set(requestLeadership());
6113
+ announce();
6114
+ void recount();
6115
+ // A page resumes because the user came back to it, so it is also the tab that should be leading.
6116
+ claimLeadership();
6117
+ };
6118
+ const isRestoredFromCache = (event) => event.persisted === true;
6119
+ const onFreeze = () => leaveElection();
6120
+ const onResume = () => rejoinElection();
6121
+ const onPageHide = (event) => {
6122
+ if (isRestoredFromCache(event))
6123
+ leaveElection();
6124
+ };
6125
+ const onPageShow = (event) => {
6126
+ if (isRestoredFromCache(event))
6127
+ rejoinElection();
6128
+ };
6129
+ const onVisibilityChange = () => {
6130
+ if (isPageVisible()) {
6131
+ claimLeadership();
6132
+ return;
6133
+ }
6134
+ if (isLeader())
6135
+ yieldLeadership();
6136
+ };
6137
+ // `freeze`/`resume` is Chromium's; `pagehide`/`pageshow` with `persisted` is how a page enters and
6138
+ // leaves the back/forward cache, which stops it just as dead in browsers that fire no `freeze`.
6139
+ if (typeof document !== 'undefined') {
6140
+ document.addEventListener('freeze', onFreeze);
6141
+ document.addEventListener('resume', onResume);
6142
+ document.addEventListener('visibilitychange', onVisibilityChange);
6143
+ }
6144
+ if (typeof window !== 'undefined') {
6145
+ window.addEventListener('pagehide', onPageHide);
6146
+ window.addEventListener('pageshow', onPageShow);
6147
+ }
5822
6148
  announce();
5823
6149
  void recount();
6150
+ // A tab that opens while another one sleeps must not wait for a visibility change that already
6151
+ // happened. The claim costs one message and settles itself when the leader answers.
6152
+ claimLeadership();
5824
6153
  const cleanup = () => {
5825
6154
  if (isDestroyed)
5826
6155
  return;
5827
6156
  isDestroyed = true;
5828
- // Release before the goodbye: the tabs that recount on it should no longer see this one queued.
5829
- hold.release();
6157
+ cancelClaim();
6158
+ if (typeof document !== 'undefined') {
6159
+ document.removeEventListener('freeze', onFreeze);
6160
+ document.removeEventListener('resume', onResume);
6161
+ document.removeEventListener('visibilitychange', onVisibilityChange);
6162
+ }
6163
+ if (typeof window !== 'undefined') {
6164
+ window.removeEventListener('pagehide', onPageHide);
6165
+ window.removeEventListener('pageshow', onPageShow);
6166
+ }
6167
+ hold().release();
5830
6168
  announce();
5831
6169
  channel?.close();
5832
6170
  refreshRequests.complete();
6171
+ refreshStarts.complete();
5833
6172
  };
5834
6173
  destroyRef.onDestroy(cleanup);
5835
6174
  return {
5836
- isLeader: hold.isHolder,
6175
+ isLeader,
5837
6176
  instanceCount: instanceCount.asReadonly(),
5838
6177
  isSupported: true,
5839
6178
  requestRefresh,
5840
6179
  refreshRequests$: refreshRequests.asObservable(),
6180
+ announceRefreshStart,
6181
+ refreshStarts$: refreshStarts.asObservable(),
5841
6182
  cleanup,
5842
6183
  };
5843
6184
  };
@@ -6013,10 +6354,38 @@ const setupMultiTabSync = (config, context) => {
6013
6354
  settleAdoption();
6014
6355
  };
6015
6356
  let adoptionTimeout;
6357
+ const requestState = () => channel.postMessage({ type: 'state-request' });
6358
+ /**
6359
+ * A page that was frozen or held in the back/forward cache ran nothing while it was away, so the
6360
+ * token pair it wakes up with is whatever it held when it stopped - the rotations it missed were
6361
+ * broadcast to a tab that was not listening. Asking again is what stops it spending a refresh token
6362
+ * that was already spent, which the server answers with a 401 and the provider with a logout for
6363
+ * every tab.
6364
+ */
6365
+ const resyncAfterResume = () => {
6366
+ if (!syncTokens || !context.accessToken())
6367
+ return;
6368
+ requestState();
6369
+ };
6370
+ const onResume = () => resyncAfterResume();
6371
+ const onPageShow = (event) => {
6372
+ if (event.persisted === true)
6373
+ resyncAfterResume();
6374
+ };
6375
+ if (typeof document !== 'undefined') {
6376
+ document.addEventListener('resume', onResume);
6377
+ }
6378
+ if (typeof window !== 'undefined') {
6379
+ window.addEventListener('pageshow', onPageShow);
6380
+ }
6016
6381
  const cleanup = () => {
6017
6382
  clearTimeout(adoptionTimeout);
6018
6383
  settleAdoption();
6019
6384
  remoteActivity.complete();
6385
+ if (typeof document !== 'undefined')
6386
+ document.removeEventListener('resume', onResume);
6387
+ if (typeof window !== 'undefined')
6388
+ window.removeEventListener('pageshow', onPageShow);
6020
6389
  channel.close();
6021
6390
  };
6022
6391
  destroyRef.onDestroy(cleanup);
@@ -6034,7 +6403,7 @@ const setupMultiTabSync = (config, context) => {
6034
6403
  };
6035
6404
  });
6036
6405
  adoptionTimeout = setTimeout(settleAdoption, sessionAdoptionTimeoutMs);
6037
- channel.postMessage({ type: 'state-request' });
6406
+ requestState();
6038
6407
  return { isPending: () => isAdoptionPending, settled };
6039
6408
  })()
6040
6409
  : undefined;
@@ -6092,7 +6461,8 @@ const SINGLE_TAB = /* @__PURE__ */ (() => {
6092
6461
  /**
6093
6462
  * Keeps one browser session consistent across the user's tabs: a login in one tab logs the others in,
6094
6463
  * a logout logs them out, and only one tab (the leader) performs the automatic token refresh so a
6095
- * single-use refresh token is not spent twice.
6464
+ * single-use refresh token is not spent twice. The leadership follows the tab the user is looking at,
6465
+ * because a hidden tab has its timers throttled and a frozen one runs none at all.
6096
6466
  *
6097
6467
  * Off by default - a single-tab app, a kiosk or an embedded webview ships neither the
6098
6468
  * `BroadcastChannel` sync nor the Web Locks leader election without it. Degrades to "this tab is the
@@ -6157,7 +6527,12 @@ const withBearerAuthMultiTabSync = (config = {}) => {
6157
6527
  return {
6158
6528
  isLeader: () => election.isLeader(),
6159
6529
  leaderElection: instance,
6160
- refreshCoordination: { request: election.requestRefresh, requests$: election.refreshRequests$ },
6530
+ refreshCoordination: {
6531
+ request: election.requestRefresh,
6532
+ requests$: election.refreshRequests$,
6533
+ announceStart: election.announceRefreshStart,
6534
+ starts$: election.refreshStarts$,
6535
+ },
6161
6536
  sessionAdoption: sync.sessionAdoption,
6162
6537
  activityCoordination: sync.activityCoordination,
6163
6538
  };
@@ -7803,7 +8178,7 @@ const armQueryDevtoolsOverrideTransfer = (recorder, ops) => {
7803
8178
 
7804
8179
  // Generated from package.json by tools/scripts/generate-version.js - do not edit.
7805
8180
  /** The version of `@ethlete/query` this build was cut from. */
7806
- const QUERY_VERSION = '6.0.0-next.38';
8181
+ const QUERY_VERSION = '6.0.0-next.39';
7807
8182
 
7808
8183
  /** How deep a schema is expanded before a nested branch is given up on. */
7809
8184
  const MAX_DEPTH = 12;
@@ -14234,8 +14609,8 @@ const defineQueryForm = (config) => {
14234
14609
  // Feed the URL value into the live model & bound controls; the effect no-ops (model === committed).
14235
14610
  model.set(clone(next));
14236
14611
  };
14237
- const applyFromUrl = (params) => {
14238
- const current = { ...committed() };
14612
+ const applyFromUrl = (params, base = committed()) => {
14613
+ const current = { ...base };
14239
14614
  let changed = false;
14240
14615
  for (const [key, def] of Object.entries(fieldDefs)) {
14241
14616
  const raw = params[paramKey(key)];
@@ -14313,7 +14688,9 @@ const defineQueryForm = (config) => {
14313
14688
  observeOptions = options;
14314
14689
  observing.set(true);
14315
14690
  if (options?.syncOnNavigation !== false) {
14316
- applyFromUrl(route.snapshot.queryParams);
14691
+ // A value written before `observe()` sits in the model only, so the URL merges onto the model
14692
+ // here. Against `committed()` any single URL param would drop that value.
14693
+ applyFromUrl(route.snapshot.queryParams, model());
14317
14694
  }
14318
14695
  // Commit whatever the model holds now (URL-restored or programmatic defaults).
14319
14696
  flush();