@steve31415/baselib 3.20.0 → 3.21.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
  } | {
@@ -34,6 +40,9 @@ export declare class BuildUpdateMonitor {
34
40
  private appliedRequestNumber;
35
41
  private trafficBuild;
36
42
  private checkFailure;
43
+ /** When the page last went from hidden to visible (not page load). */
44
+ private returnedAt;
45
+ private deferUntilHidden;
37
46
  constructor(deps: BuildUpdateDeps);
38
47
  getState: () => BuildUpdateState;
39
48
  subscribe: (listener: () => void) => (() => void);
@@ -49,6 +58,7 @@ export declare class BuildUpdateMonitor {
49
58
  private observeTrafficBuild;
50
59
  private markAvailable;
51
60
  private maybeReload;
61
+ private justReturned;
52
62
  private setState;
53
63
  /** Polls only while visible: an interval in a hidden page would wake it
54
64
  * every few minutes to do nothing. Hidden pages learn of deploys from the
@@ -2,6 +2,12 @@
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';
@@ -18,6 +24,9 @@ export class BuildUpdateMonitor {
18
24
  appliedRequestNumber = 0;
19
25
  trafficBuild = null;
20
26
  checkFailure = false;
27
+ /** When the page last went from hidden to visible (not page load). */
28
+ returnedAt = null;
29
+ deferUntilHidden = false;
21
30
  constructor(deps) {
22
31
  this.deps = deps;
23
32
  }
@@ -116,6 +125,7 @@ export class BuildUpdateMonitor {
116
125
  if (buildId === this.deps.currentBuildId) {
117
126
  if (this.state.kind === 'available')
118
127
  this.setState({ kind: 'current' });
128
+ this.deferUntilHidden = false;
119
129
  return;
120
130
  }
121
131
  this.markAvailable(buildId);
@@ -129,12 +139,22 @@ export class BuildUpdateMonitor {
129
139
  observedBuildId: buildId,
130
140
  });
131
141
  this.setState({ kind: 'available', buildId });
142
+ if (!this.deferUntilHidden && this.justReturned()) {
143
+ this.deferUntilHidden = true;
144
+ this.deps.logger.info('build reload deferred until hidden', {
145
+ pageBuildId: this.deps.currentBuildId,
146
+ observedBuildId: buildId,
147
+ sinceReturnMs: Date.now() - (this.returnedAt ?? Date.now()),
148
+ });
149
+ }
132
150
  }
133
151
  this.maybeReload();
134
152
  }
135
153
  maybeReload() {
136
154
  if (this.state.kind !== 'available' || !this.deps.safety.isSafe())
137
155
  return;
156
+ if (this.deferUntilHidden && document.visibilityState === 'visible')
157
+ return;
138
158
  const buildId = this.state.buildId;
139
159
  this.setState({ kind: 'reloading', buildId });
140
160
  if (this.deps.reload)
@@ -142,6 +162,11 @@ export class BuildUpdateMonitor {
142
162
  else
143
163
  location.reload();
144
164
  }
165
+ justReturned() {
166
+ return (document.visibilityState === 'visible' &&
167
+ this.returnedAt !== null &&
168
+ Date.now() - this.returnedAt < RETURN_RELOAD_DEFER_MS);
169
+ }
145
170
  setState(state) {
146
171
  this.state = state;
147
172
  for (const listener of this.listeners)
@@ -165,11 +190,15 @@ export class BuildUpdateMonitor {
165
190
  }
166
191
  onVisibility = () => {
167
192
  if (document.visibilityState === 'visible') {
193
+ this.returnedAt = Date.now();
168
194
  this.startPolling();
169
195
  void this.checkNow();
170
196
  }
171
197
  else {
198
+ this.returnedAt = null;
172
199
  this.stopPolling();
200
+ // A reload deferred at the return happens now, out of sight.
201
+ this.maybeReload();
173
202
  }
174
203
  };
175
204
  onOnline = () => {
@@ -127,7 +127,11 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
127
127
  private readonly abortControllers;
128
128
  private readonly removeListeners;
129
129
  private currentState;
130
+ /** Failed attempts in the current visible spell: drives the ladder and the
131
+ * outage log's `attempts` (what the user could have waited through). */
130
132
  private reconnectAttempts;
133
+ /** Failed visible attempts across the whole outage (every spell). */
134
+ private totalAttempts;
131
135
  private reconnectTimer;
132
136
  private countdownTimer;
133
137
  private pingTimer;
@@ -216,6 +220,11 @@ export declare class SyncClient<B, C extends EventBody, S extends EventBody, Sum
216
220
  /** One INFO per hidden spell: what the user saw change on return. */
217
221
  private finishReturnWindow;
218
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;
219
228
  private scheduleReconnect;
220
229
  private probeSession;
221
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 {
@@ -47,7 +53,11 @@ export class SyncClient {
47
53
  abortControllers = new Set();
48
54
  removeListeners = [];
49
55
  currentState = { kind: 'starting', pending: 0 };
56
+ /** Failed attempts in the current visible spell: drives the ladder and the
57
+ * outage log's `attempts` (what the user could have waited through). */
50
58
  reconnectAttempts = 0;
59
+ /** Failed visible attempts across the whole outage (every spell). */
60
+ totalAttempts = 0;
51
61
  reconnectTimer = null;
52
62
  countdownTimer = null;
53
63
  pingTimer = null;
@@ -324,6 +334,10 @@ export class SyncClient {
324
334
  this.rearmFailureTimers();
325
335
  if (this.outageStartedAt !== null && !this.outageEscalated)
326
336
  this.armEscalation();
337
+ // Each return starts the ladder afresh: failures from earlier glances
338
+ // (a phone shown for a second, often before its network is back) must
339
+ // not stretch this return's retry to 8-30 s or add up to a WARN.
340
+ this.reconnectAttempts = 0;
327
341
  }
328
342
  this.wakeConnection();
329
343
  // Always: a socket that looks open may be dead (review S2), and a
@@ -457,6 +471,7 @@ export class SyncClient {
457
471
  this.plannedClose = null;
458
472
  this.startOutage(event.code, event.wasClean === true);
459
473
  if (!this.isHidden()) {
474
+ this.noteReturnFailure(event.code);
460
475
  this.scheduleReconnect();
461
476
  return;
462
477
  }
@@ -635,6 +650,9 @@ export class SyncClient {
635
650
  catchupOk: false,
636
651
  eventsOnReturn: 0,
637
652
  resyncOnReturn: 0,
653
+ failures: 0,
654
+ firstFailure: null,
655
+ quickRetryUsed: false,
638
656
  timer: this.options.runtime.setTimeout(() => this.finishReturnWindow(), RETURN_REPORT_TIMEOUT_MS),
639
657
  };
640
658
  }
@@ -657,6 +675,11 @@ export class SyncClient {
657
675
  catchupOk: window.catchupOk,
658
676
  eventsOnReturn: window.eventsOnReturn,
659
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,
660
683
  sinceLastContactMs: window.sinceLastContactMs,
661
684
  backgroundCatchups: window.spell.backgroundCatchups,
662
685
  backgroundCatchupFailures: window.spell.backgroundCatchupFailures,
@@ -675,9 +698,37 @@ export class SyncClient {
675
698
  this.countdownTimer = null;
676
699
  this.connect();
677
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
+ }
678
726
  scheduleReconnect() {
679
- 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);
680
730
  this.reconnectAttempts++;
731
+ this.totalAttempts++;
681
732
  if (this.reconnectAttempts >= PROBE_AFTER_ATTEMPTS)
682
733
  void this.probeSession();
683
734
  let remaining = delay;
@@ -694,7 +745,7 @@ export class SyncClient {
694
745
  this.countdownTimer = null;
695
746
  this.reconnectTimer = null;
696
747
  this.connect();
697
- }, delay * 1000);
748
+ }, delayMs);
698
749
  }
699
750
  async probeSession() {
700
751
  if (this.halted || this.disposed || this.probeInFlight)
@@ -1060,13 +1111,15 @@ export class SyncClient {
1060
1111
  if (this.outageStartedAt !== null) {
1061
1112
  const durationMs = Math.max(0, this.options.runtime.now() - this.outageStartedAt);
1062
1113
  // Failures are only counted (and the ladder only runs) while visible,
1063
- // so `attempts` is what the user could have waited through.
1114
+ // and the count restarts on each return, so `attempts` is what the
1115
+ // user could have waited through; `totalAttempts` spans every spell.
1064
1116
  const attempts = this.reconnectAttempts;
1065
1117
  const info = this.outageInfo;
1066
1118
  const delta = info ? SyncClient.delta(info.counters, this.snapshot()) : null;
1067
1119
  const fields = {
1068
1120
  durationMs,
1069
1121
  attempts,
1122
+ totalAttempts: this.totalAttempts,
1070
1123
  closeCode: info?.closeCode,
1071
1124
  wasClean: info?.wasClean,
1072
1125
  visibleAtDrop: info?.visibleAtDrop,
@@ -1100,6 +1153,7 @@ export class SyncClient {
1100
1153
  this.options.runtime.clearTimeout(this.escalateTimer);
1101
1154
  this.escalateTimer = null;
1102
1155
  this.reconnectAttempts = 0;
1156
+ this.totalAttempts = 0;
1103
1157
  for (const [eventGuid, entry] of this.pending) {
1104
1158
  if (this.settledPending.has(eventGuid)) {
1105
1159
  this.retrySettlement(eventGuid);
@@ -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.0",
3
+ "version": "3.21.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",