@steve31415/baselib 3.20.1 → 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 = () => {
@@ -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.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",