@steve31415/baselib 3.20.1 → 3.22.0
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/dist/app-update-browser/build-update.d.ts +21 -0
- package/dist/app-update-browser/build-update.js +53 -0
- package/dist/sync-browser/client.d.ts +5 -0
- package/dist/sync-browser/client.js +44 -2
- package/dist/sync-browser/index.d.ts +1 -0
- package/dist/sync-browser/index.js +1 -0
- package/dist/sync-browser/quiet-status.d.ts +23 -0
- package/dist/sync-browser/quiet-status.js +34 -0
- package/package.json +1 -1
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import type { Logger } from '../log-browser.js';
|
|
2
|
+
/** A newer build learned this soon after the page became visible was found
|
|
3
|
+
* *because* the user returned (a frozen tab learns of deploys only then):
|
|
4
|
+
* reloading now would replace the page just as the eyes land on it
|
|
5
|
+
* (plasticine-way PERFORMANCE.md, "No updates on foreground"). Such a reload
|
|
6
|
+
* waits for the page to be hidden again. */
|
|
7
|
+
export declare const RETURN_RELOAD_DEFER_MS = 10000;
|
|
2
8
|
export type BuildUpdateState = {
|
|
3
9
|
kind: 'current';
|
|
4
10
|
} | {
|
|
@@ -20,8 +26,17 @@ export interface BuildUpdateDeps {
|
|
|
20
26
|
reload?: () => void;
|
|
21
27
|
pollMs?: number;
|
|
22
28
|
onTrafficBuildChange?: (buildId: string) => void;
|
|
29
|
+
/** Whether this document's HTML came from the browser cache without a
|
|
30
|
+
* request (default: `shellServedFromCache`). */
|
|
31
|
+
shellFromCache?: () => boolean;
|
|
23
32
|
}
|
|
24
33
|
export declare function pageBuildId(): string;
|
|
34
|
+
/** True when this page's HTML was served from the browser cache without
|
|
35
|
+
* reaching the server. Android Chrome does this when it restores a tab whose
|
|
36
|
+
* renderer it killed in the background, even for a `no-cache` shell, so the
|
|
37
|
+
* restored page can run a build older than current traffic (reader,
|
|
38
|
+
* 2026-09-26/27: 9 of 15 phone page loads). */
|
|
39
|
+
export declare function shellServedFromCache(): boolean;
|
|
25
40
|
export declare class BuildUpdateMonitor {
|
|
26
41
|
private deps;
|
|
27
42
|
private state;
|
|
@@ -34,6 +49,11 @@ export declare class BuildUpdateMonitor {
|
|
|
34
49
|
private appliedRequestNumber;
|
|
35
50
|
private trafficBuild;
|
|
36
51
|
private checkFailure;
|
|
52
|
+
/** When the page last went from hidden to visible, or was loaded visible
|
|
53
|
+
* from a cached shell (a restored tab is a return in all but name). */
|
|
54
|
+
private returnedAt;
|
|
55
|
+
private restoredFromCache;
|
|
56
|
+
private deferUntilHidden;
|
|
37
57
|
constructor(deps: BuildUpdateDeps);
|
|
38
58
|
getState: () => BuildUpdateState;
|
|
39
59
|
subscribe: (listener: () => void) => (() => void);
|
|
@@ -49,6 +69,7 @@ export declare class BuildUpdateMonitor {
|
|
|
49
69
|
private observeTrafficBuild;
|
|
50
70
|
private markAvailable;
|
|
51
71
|
private maybeReload;
|
|
72
|
+
private justReturned;
|
|
52
73
|
private setState;
|
|
53
74
|
/** Polls only while visible: an interval in a hidden page would wake it
|
|
54
75
|
* every few minutes to do nothing. Hidden pages learn of deploys from the
|
|
@@ -2,10 +2,29 @@
|
|
|
2
2
|
// sync observations are an opportunistic second signal. Reload policy is
|
|
3
3
|
// separate and injected through ReloadSafety.
|
|
4
4
|
const DEFAULT_POLL_MS = 5 * 60 * 1000;
|
|
5
|
+
/** A newer build learned this soon after the page became visible was found
|
|
6
|
+
* *because* the user returned (a frozen tab learns of deploys only then):
|
|
7
|
+
* reloading now would replace the page just as the eyes land on it
|
|
8
|
+
* (plasticine-way PERFORMANCE.md, "No updates on foreground"). Such a reload
|
|
9
|
+
* waits for the page to be hidden again. */
|
|
10
|
+
export const RETURN_RELOAD_DEFER_MS = 10_000;
|
|
5
11
|
const LEGACY_BUILD = 'legacy-pre-build-id';
|
|
6
12
|
export function pageBuildId() {
|
|
7
13
|
return document.querySelector('meta[name="pw-build-id"]')?.content || 'development';
|
|
8
14
|
}
|
|
15
|
+
/** True when this page's HTML was served from the browser cache without
|
|
16
|
+
* reaching the server. Android Chrome does this when it restores a tab whose
|
|
17
|
+
* renderer it killed in the background, even for a `no-cache` shell, so the
|
|
18
|
+
* restored page can run a build older than current traffic (reader,
|
|
19
|
+
* 2026-09-26/27: 9 of 15 phone page loads). */
|
|
20
|
+
export function shellServedFromCache() {
|
|
21
|
+
const entry = globalThis.performance?.getEntriesByType?.('navigation')[0];
|
|
22
|
+
if (!entry)
|
|
23
|
+
return false;
|
|
24
|
+
if (entry.deliveryType === 'cache')
|
|
25
|
+
return true;
|
|
26
|
+
return entry.transferSize === 0 && entry.decodedBodySize > 0;
|
|
27
|
+
}
|
|
9
28
|
export class BuildUpdateMonitor {
|
|
10
29
|
deps;
|
|
11
30
|
state = { kind: 'current' };
|
|
@@ -18,6 +37,11 @@ export class BuildUpdateMonitor {
|
|
|
18
37
|
appliedRequestNumber = 0;
|
|
19
38
|
trafficBuild = null;
|
|
20
39
|
checkFailure = false;
|
|
40
|
+
/** When the page last went from hidden to visible, or was loaded visible
|
|
41
|
+
* from a cached shell (a restored tab is a return in all but name). */
|
|
42
|
+
returnedAt = null;
|
|
43
|
+
restoredFromCache = false;
|
|
44
|
+
deferUntilHidden = false;
|
|
21
45
|
constructor(deps) {
|
|
22
46
|
this.deps = deps;
|
|
23
47
|
}
|
|
@@ -32,6 +56,13 @@ export class BuildUpdateMonitor {
|
|
|
32
56
|
this.started = true;
|
|
33
57
|
this.lifecycle += 1;
|
|
34
58
|
this.stopSafety = this.deps.safety.subscribe(() => this.maybeReload());
|
|
59
|
+
// A tab restored from the cache shows whatever build it last loaded; a
|
|
60
|
+
// reload now would replace it just as it appears. Treat it as a return.
|
|
61
|
+
// (A hidden restore needs nothing: its reload is out of sight anyway.)
|
|
62
|
+
if (document.visibilityState === 'visible' && (this.deps.shellFromCache ?? shellServedFromCache)()) {
|
|
63
|
+
this.returnedAt = Date.now();
|
|
64
|
+
this.restoredFromCache = true;
|
|
65
|
+
}
|
|
35
66
|
if (document.visibilityState === 'visible')
|
|
36
67
|
this.startPolling();
|
|
37
68
|
document.addEventListener('visibilitychange', this.onVisibility);
|
|
@@ -116,6 +147,7 @@ export class BuildUpdateMonitor {
|
|
|
116
147
|
if (buildId === this.deps.currentBuildId) {
|
|
117
148
|
if (this.state.kind === 'available')
|
|
118
149
|
this.setState({ kind: 'current' });
|
|
150
|
+
this.deferUntilHidden = false;
|
|
119
151
|
return;
|
|
120
152
|
}
|
|
121
153
|
this.markAvailable(buildId);
|
|
@@ -129,12 +161,23 @@ export class BuildUpdateMonitor {
|
|
|
129
161
|
observedBuildId: buildId,
|
|
130
162
|
});
|
|
131
163
|
this.setState({ kind: 'available', buildId });
|
|
164
|
+
if (!this.deferUntilHidden && this.justReturned()) {
|
|
165
|
+
this.deferUntilHidden = true;
|
|
166
|
+
this.deps.logger.info('build reload deferred until hidden', {
|
|
167
|
+
pageBuildId: this.deps.currentBuildId,
|
|
168
|
+
observedBuildId: buildId,
|
|
169
|
+
sinceReturnMs: Date.now() - (this.returnedAt ?? Date.now()),
|
|
170
|
+
restoredFromCache: this.restoredFromCache,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
132
173
|
}
|
|
133
174
|
this.maybeReload();
|
|
134
175
|
}
|
|
135
176
|
maybeReload() {
|
|
136
177
|
if (this.state.kind !== 'available' || !this.deps.safety.isSafe())
|
|
137
178
|
return;
|
|
179
|
+
if (this.deferUntilHidden && document.visibilityState === 'visible')
|
|
180
|
+
return;
|
|
138
181
|
const buildId = this.state.buildId;
|
|
139
182
|
this.setState({ kind: 'reloading', buildId });
|
|
140
183
|
if (this.deps.reload)
|
|
@@ -142,6 +185,11 @@ export class BuildUpdateMonitor {
|
|
|
142
185
|
else
|
|
143
186
|
location.reload();
|
|
144
187
|
}
|
|
188
|
+
justReturned() {
|
|
189
|
+
return (document.visibilityState === 'visible' &&
|
|
190
|
+
this.returnedAt !== null &&
|
|
191
|
+
Date.now() - this.returnedAt < RETURN_RELOAD_DEFER_MS);
|
|
192
|
+
}
|
|
145
193
|
setState(state) {
|
|
146
194
|
this.state = state;
|
|
147
195
|
for (const listener of this.listeners)
|
|
@@ -165,11 +213,16 @@ export class BuildUpdateMonitor {
|
|
|
165
213
|
}
|
|
166
214
|
onVisibility = () => {
|
|
167
215
|
if (document.visibilityState === 'visible') {
|
|
216
|
+
this.returnedAt = Date.now();
|
|
217
|
+
this.restoredFromCache = false;
|
|
168
218
|
this.startPolling();
|
|
169
219
|
void this.checkNow();
|
|
170
220
|
}
|
|
171
221
|
else {
|
|
222
|
+
this.returnedAt = null;
|
|
172
223
|
this.stopPolling();
|
|
224
|
+
// A reload deferred at the return happens now, out of sight.
|
|
225
|
+
this.maybeReload();
|
|
173
226
|
}
|
|
174
227
|
};
|
|
175
228
|
onOnline = () => {
|
|
@@ -220,6 +220,11 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
|
|
|
220
220
|
/** One INFO per hidden spell: what the user saw change on return. */
|
|
221
221
|
private finishReturnWindow;
|
|
222
222
|
private connectNow;
|
|
223
|
+
/** Records a visible socket failure inside a return window. */
|
|
224
|
+
private noteReturnFailure;
|
|
225
|
+
/** The first failure of a return, before sync is back, retries quickly
|
|
226
|
+
* (once per return); anything else takes the ladder. */
|
|
227
|
+
private takeQuickRetry;
|
|
223
228
|
private scheduleReconnect;
|
|
224
229
|
private probeSession;
|
|
225
230
|
private startOutage;
|
|
@@ -21,6 +21,12 @@ const WAKE_CONNECT_REPLY_MS = 5_000;
|
|
|
21
21
|
/** A hidden attempt's socket that dies sooner than this counts as failed. */
|
|
22
22
|
const HIDDEN_SOCKET_MIN_LIFE_MS = 60_000;
|
|
23
23
|
const RETURN_REPORT_TIMEOUT_MS = 15_000;
|
|
24
|
+
/** The first visible failure of a return retries after this instead of the
|
|
25
|
+
* ladder's 1 s. After a phone tab was frozen, its old socket turns out dead
|
|
26
|
+
* (its close lands ~30 ms after the return) or the first new handshake fails
|
|
27
|
+
* without reaching the server, while HTTP works at once; the ladder's 1 s
|
|
28
|
+
* step then made every such return take 1.5-2 s (reader, 2026-09-26). */
|
|
29
|
+
const RETURN_QUICK_RETRY_MS = 250;
|
|
24
30
|
const SOCKET_CONNECTING = 0;
|
|
25
31
|
const SOCKET_OPEN = 1;
|
|
26
32
|
class LifecycleAbort {
|
|
@@ -465,6 +471,7 @@ export class SyncClient {
|
|
|
465
471
|
this.plannedClose = null;
|
|
466
472
|
this.startOutage(event.code, event.wasClean === true);
|
|
467
473
|
if (!this.isHidden()) {
|
|
474
|
+
this.noteReturnFailure(event.code);
|
|
468
475
|
this.scheduleReconnect();
|
|
469
476
|
return;
|
|
470
477
|
}
|
|
@@ -643,6 +650,9 @@ export class SyncClient {
|
|
|
643
650
|
catchupOk: false,
|
|
644
651
|
eventsOnReturn: 0,
|
|
645
652
|
resyncOnReturn: 0,
|
|
653
|
+
failures: 0,
|
|
654
|
+
firstFailure: null,
|
|
655
|
+
quickRetryUsed: false,
|
|
646
656
|
timer: this.options.runtime.setTimeout(() => this.finishReturnWindow(), RETURN_REPORT_TIMEOUT_MS),
|
|
647
657
|
};
|
|
648
658
|
}
|
|
@@ -665,6 +675,11 @@ export class SyncClient {
|
|
|
665
675
|
catchupOk: window.catchupOk,
|
|
666
676
|
eventsOnReturn: window.eventsOnReturn,
|
|
667
677
|
resyncOnReturn: window.resyncOnReturn,
|
|
678
|
+
returnFailures: window.failures,
|
|
679
|
+
firstFailureAfterReturnMs: window.firstFailure?.afterReturnMs ?? null,
|
|
680
|
+
firstFailureSocketAgeMs: window.firstFailure?.socketAgeMs ?? null,
|
|
681
|
+
firstFailureOpened: window.firstFailure?.opened ?? null,
|
|
682
|
+
firstFailureCloseCode: window.firstFailure?.closeCode ?? null,
|
|
668
683
|
sinceLastContactMs: window.sinceLastContactMs,
|
|
669
684
|
backgroundCatchups: window.spell.backgroundCatchups,
|
|
670
685
|
backgroundCatchupFailures: window.spell.backgroundCatchupFailures,
|
|
@@ -683,8 +698,35 @@ export class SyncClient {
|
|
|
683
698
|
this.countdownTimer = null;
|
|
684
699
|
this.connect();
|
|
685
700
|
}
|
|
701
|
+
/** Records a visible socket failure inside a return window. */
|
|
702
|
+
noteReturnFailure(closeCode) {
|
|
703
|
+
const window = this.returnWindow;
|
|
704
|
+
if (window === null || window.establishedAt !== null)
|
|
705
|
+
return;
|
|
706
|
+
window.failures++;
|
|
707
|
+
if (window.firstFailure !== null)
|
|
708
|
+
return;
|
|
709
|
+
const now = this.now();
|
|
710
|
+
window.firstFailure = {
|
|
711
|
+
afterReturnMs: Math.max(0, now - window.visibleAt),
|
|
712
|
+
socketAgeMs: Math.max(0, now - this.socketCreatedAt),
|
|
713
|
+
opened: this.socketOpenedAt !== null,
|
|
714
|
+
closeCode,
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
/** The first failure of a return, before sync is back, retries quickly
|
|
718
|
+
* (once per return); anything else takes the ladder. */
|
|
719
|
+
takeQuickRetry() {
|
|
720
|
+
const window = this.returnWindow;
|
|
721
|
+
if (window === null || window.establishedAt !== null || window.quickRetryUsed)
|
|
722
|
+
return false;
|
|
723
|
+
window.quickRetryUsed = true;
|
|
724
|
+
return true;
|
|
725
|
+
}
|
|
686
726
|
scheduleReconnect() {
|
|
687
|
-
const
|
|
727
|
+
const ladderSeconds = RECONNECT_DELAYS_SECONDS[Math.min(this.reconnectAttempts, RECONNECT_DELAYS_SECONDS.length - 1)];
|
|
728
|
+
const delayMs = this.takeQuickRetry() ? RETURN_QUICK_RETRY_MS : ladderSeconds * 1000;
|
|
729
|
+
const delay = Math.ceil(delayMs / 1000);
|
|
688
730
|
this.reconnectAttempts++;
|
|
689
731
|
this.totalAttempts++;
|
|
690
732
|
if (this.reconnectAttempts >= PROBE_AFTER_ATTEMPTS)
|
|
@@ -703,7 +745,7 @@ export class SyncClient {
|
|
|
703
745
|
this.countdownTimer = null;
|
|
704
746
|
this.reconnectTimer = null;
|
|
705
747
|
this.connect();
|
|
706
|
-
},
|
|
748
|
+
}, delayMs);
|
|
707
749
|
}
|
|
708
750
|
async probeSession() {
|
|
709
751
|
if (this.halted || this.disposed || this.probeInFlight)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { SyncClientState } from './client.js';
|
|
2
|
+
/** How long a quiet transition must last while visible before it shows.
|
|
3
|
+
* After a frozen phone tab returns, reconnecting took 1.5-2.1 s (reader,
|
|
4
|
+
* 2026-09-26); 3 s leaves margin over that. */
|
|
5
|
+
export declare const QUIET_TRANSITION_GRACE_MS = 3000;
|
|
6
|
+
/** A short-lived connection state with no unsynced edits behind it. */
|
|
7
|
+
export declare function isQuietTransition(status: SyncClientState, pendingCount: number): boolean;
|
|
8
|
+
/** What to show now: a quiet transition only after it has lasted the grace
|
|
9
|
+
* period of visible time (counted from when it began or the page last became
|
|
10
|
+
* visible, whichever is later; pass `visibleSince = now` while hidden); until
|
|
11
|
+
* then, whatever was shown before it (`null` = nothing yet). Anything else
|
|
12
|
+
* shows at once. */
|
|
13
|
+
export declare function statusToShow<V>(args: {
|
|
14
|
+
view: V;
|
|
15
|
+
quiet: boolean;
|
|
16
|
+
quietSince: number | null;
|
|
17
|
+
visibleSince: number;
|
|
18
|
+
lastShown: V | null;
|
|
19
|
+
now: number;
|
|
20
|
+
graceMs?: number;
|
|
21
|
+
}): V | null;
|
|
22
|
+
/** When the running grace period began (for scheduling a re-render at its end). */
|
|
23
|
+
export declare function quietGraceStart(quietSince: number | null, visibleSince: number, now: number): number;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Whether a sync status indicator should admit to a reconnect yet. Pure, so
|
|
2
|
+
// apps' pills (React or not) share one rule: a reconnect with nothing at
|
|
3
|
+
// stake is shown only once it has lasted a grace period of *visible* time,
|
|
4
|
+
// so returning to a tab doesn't flash "Connecting…"/"Offline" just as the
|
|
5
|
+
// eyes land on it (plasticine-way PERFORMANCE.md, "No updates on
|
|
6
|
+
// foreground"). Unsynced edits and errors always show at once.
|
|
7
|
+
/** How long a quiet transition must last while visible before it shows.
|
|
8
|
+
* After a frozen phone tab returns, reconnecting took 1.5-2.1 s (reader,
|
|
9
|
+
* 2026-09-26); 3 s leaves margin over that. */
|
|
10
|
+
export const QUIET_TRANSITION_GRACE_MS = 3000;
|
|
11
|
+
/** A short-lived connection state with no unsynced edits behind it. */
|
|
12
|
+
export function isQuietTransition(status, pendingCount) {
|
|
13
|
+
if (pendingCount > 0)
|
|
14
|
+
return false;
|
|
15
|
+
return (status.kind === 'starting' ||
|
|
16
|
+
status.kind === 'connecting' ||
|
|
17
|
+
status.kind === 'reconnecting' ||
|
|
18
|
+
status.kind === 'disconnected');
|
|
19
|
+
}
|
|
20
|
+
/** What to show now: a quiet transition only after it has lasted the grace
|
|
21
|
+
* period of visible time (counted from when it began or the page last became
|
|
22
|
+
* visible, whichever is later; pass `visibleSince = now` while hidden); until
|
|
23
|
+
* then, whatever was shown before it (`null` = nothing yet). Anything else
|
|
24
|
+
* shows at once. */
|
|
25
|
+
export function statusToShow(args) {
|
|
26
|
+
const { view, quiet, quietSince, visibleSince, lastShown, now } = args;
|
|
27
|
+
if (!quiet)
|
|
28
|
+
return view;
|
|
29
|
+
return now - quietGraceStart(quietSince, visibleSince, now) >= (args.graceMs ?? QUIET_TRANSITION_GRACE_MS) ? view : lastShown;
|
|
30
|
+
}
|
|
31
|
+
/** When the running grace period began (for scheduling a re-render at its end). */
|
|
32
|
+
export function quietGraceStart(quietSince, visibleSince, now) {
|
|
33
|
+
return Math.max(quietSince ?? now, visibleSince);
|
|
34
|
+
}
|
package/package.json
CHANGED