@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.
@@ -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 delay = RECONNECT_DELAYS_SECONDS[Math.min(this.reconnectAttempts, RECONNECT_DELAYS_SECONDS.length - 1)];
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
- }, delay * 1000);
748
+ }, delayMs);
707
749
  }
708
750
  async probeSession() {
709
751
  if (this.halted || this.disposed || this.probeInFlight)
@@ -1,2 +1,3 @@
1
1
  export * from './outbox.js';
2
2
  export * from './client.js';
3
+ export * from './quiet-status.js';
@@ -1,2 +1,3 @@
1
1
  export * from './outbox.js';
2
2
  export * from './client.js';
3
+ export * from './quiet-status.js';
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve31415/baselib",
3
- "version": "3.20.1",
3
+ "version": "3.22.0",
4
4
  "description": "Plasticine new-world shared platform library: logging, auth, service-to-service auth, db, HTTP, sync, app updates",
5
5
  "type": "module",
6
6
  "license": "MIT",