@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.
- package/CHANGELOG.md +15 -0
- package/fesm2022/ethlete-query-testing.mjs +34 -2
- package/fesm2022/ethlete-query-testing.mjs.map +1 -1
- package/fesm2022/ethlete-query.mjs +431 -54
- package/fesm2022/ethlete-query.mjs.map +1 -1
- package/package.json +1 -1
- package/types/ethlete-query-testing.d.ts +5 -1
- package/types/ethlete-query.d.ts +48 -10
|
@@ -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}`,
|
|
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
|
|
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
|
|
5394
|
-
// identical across tabs, so the leader's own timer is due at the same
|
|
5395
|
-
// only tab allowed to act.
|
|
5396
|
-
//
|
|
5397
|
-
|
|
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
|
|
5431
|
-
*
|
|
5432
|
-
*
|
|
5433
|
-
*
|
|
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
|
|
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
|
|
5484
|
-
const
|
|
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
|
-
|
|
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
|
|
5521
|
-
|
|
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
|
|
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
|
|
5759
|
-
*
|
|
5760
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
5800
|
-
const
|
|
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' &&
|
|
6078
|
+
if (message?.type === 'refresh-requested' && isLeader()) {
|
|
5810
6079
|
refreshRequests.next();
|
|
5811
6080
|
}
|
|
5812
6081
|
};
|
|
5813
6082
|
}
|
|
5814
6083
|
effect(() => {
|
|
5815
|
-
if (!
|
|
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
|
-
|
|
5829
|
-
|
|
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
|
|
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
|
-
|
|
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: {
|
|
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.
|
|
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 = { ...
|
|
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
|
-
|
|
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();
|