portal-mojo 0.2.13 → 0.2.15

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.
@@ -6,7 +6,7 @@
6
6
 
7
7
  export interface SeriesLike {
8
8
  label: string;
9
- data: number[];
9
+ data: (number | null | undefined)[];
10
10
  }
11
11
 
12
12
  export interface SeriesStat {
@@ -31,15 +31,17 @@ export function toNumber(v: unknown): number {
31
31
  /**
32
32
  * Per-series summary stats. Empty datasets produce no entry (the caller
33
33
  * renders its "No data" state); an empty series inside a non-empty set
34
- * yields a zeroed row so the table stays aligned with the legend.
34
+ * yields unavailable (NaN) values so the table stays aligned with the legend.
35
+ * Aggregates use finite observations only; Latest refers to the final sample.
35
36
  */
36
37
  export function computeSeriesStats(datasets: readonly SeriesLike[] | null | undefined): SeriesStat[] {
37
38
  // (Array.isArray would widen the readonly array to any[] — a TS quirk.)
38
39
  const list: readonly SeriesLike[] = datasets ?? [];
39
40
  return list.map((d) => {
40
- const values = (Array.isArray(d.data) ? d.data : []).map(toNumber);
41
+ const data = Array.isArray(d.data) ? d.data : [];
42
+ const values = data.filter((v): v is number => typeof v === 'number' && Number.isFinite(v));
41
43
  if (values.length === 0) {
42
- return { label: d.label ?? '', count: 0, sum: 0, avg: 0, median: 0, min: 0, max: 0, latest: 0 };
44
+ return { label: d.label ?? '', count: 0, sum: NaN, avg: NaN, median: NaN, min: NaN, max: NaN, latest: NaN };
43
45
  }
44
46
  const sorted = [...values].sort((a, b) => a - b);
45
47
  const sum = values.reduce((a, b) => a + b, 0);
@@ -53,14 +55,14 @@ export function computeSeriesStats(datasets: readonly SeriesLike[] | null | unde
53
55
  median,
54
56
  min: sorted[0]!,
55
57
  max: sorted[sorted.length - 1]!,
56
- latest: values[values.length - 1]!,
58
+ latest: Number.isFinite(data.at(-1)) ? data.at(-1)! : NaN,
57
59
  };
58
60
  });
59
61
  }
60
62
 
61
63
  /** '–' for nothing; integers plain; decimals to 2 places (source parity). */
62
64
  export function defaultStatFormat(n: number | null | undefined): string {
63
- if (n == null || Number.isNaN(n)) return '–';
65
+ if (n == null || !Number.isFinite(n)) return '–';
64
66
  if (Number.isInteger(n)) return n.toLocaleString();
65
67
  return Number(n).toLocaleString(undefined, { maximumFractionDigits: 2 });
66
68
  }
@@ -61,13 +61,17 @@ export function sessionIsPersistent(): boolean {
61
61
  }
62
62
 
63
63
  export function setTokens(access: string, refresh?: string | null, persistent = true): void {
64
+ storeTokens(access, refresh, persistent, 'session');
65
+ }
66
+
67
+ function storeTokens(access: string, refresh: string | null | undefined, persistent: boolean, source: 'session' | 'refresh'): void {
64
68
  // Clear both storages first — a leftover token in the other storage must
65
69
  // never shadow this session (web-mojo wrote without clearing).
66
70
  clearTokens({ silent: true });
67
71
  const storage = persistent ? localStorage : sessionStorage;
68
72
  storage.setItem(ACCESS_KEY, access);
69
73
  if (refresh) storage.setItem(REFRESH_KEY, refresh);
70
- notifyAuthChanged();
74
+ notifyAuthChanged(source);
71
75
  }
72
76
 
73
77
  export function clearTokens(opts: { silent?: boolean } = {}): void {
@@ -89,8 +93,14 @@ export interface AuthSnapshot {
89
93
 
90
94
  let snapshotCache: AuthSnapshot = { authenticated: false, uid: null, email: null };
91
95
  let snapshotKey = '∅';
96
+ let sessionRevision = 0;
92
97
  const authListeners = new Set<() => void>();
93
98
 
99
+ /** @internal Same-tab replacement fence; ordinary renewal does not advance it. */
100
+ export function getAuthSessionRevision(): number {
101
+ return sessionRevision;
102
+ }
103
+
94
104
  function computeSnapshot(): void {
95
105
  const access = getAccessToken();
96
106
  const refresh = getRefreshToken();
@@ -106,7 +116,8 @@ function computeSnapshot(): void {
106
116
  };
107
117
  }
108
118
 
109
- function notifyAuthChanged(): void {
119
+ function notifyAuthChanged(source: 'session' | 'refresh' = 'session'): void {
120
+ if (source === 'session') sessionRevision += 1;
110
121
  computeSnapshot();
111
122
  for (const cb of authListeners) cb();
112
123
  }
@@ -212,10 +223,12 @@ async function doRefresh(): Promise<RefreshOutcome> {
212
223
  const superseded = (): boolean => getRefreshToken() !== refresh;
213
224
  const sessionUsable = (): boolean => checkTokenStatus().action !== 'logout';
214
225
  try {
215
- const body = await mojoCall(REFRESH_PATH, { method: 'POST', body: { refresh_token: refresh } });
226
+ const body = await mojoCall(REFRESH_PATH, {
227
+ method: 'POST', body: { refresh_token: refresh }, signal: AbortSignal.timeout(15_000),
228
+ });
216
229
  if (superseded()) return { ok: sessionUsable() };
217
230
  const pkg = body.data as { access_token: string; refresh_token?: string };
218
- setTokens(pkg.access_token, pkg.refresh_token, persistent);
231
+ storeTokens(pkg.access_token, pkg.refresh_token, persistent, 'refresh');
219
232
  emitAuth('refreshed');
220
233
  return { ok: true };
221
234
  } catch (error) {
@@ -3,7 +3,8 @@ import {
3
3
  useSyncExternalStore, type PropsWithChildren,
4
4
  } from 'react';
5
5
  import { apiOrigin } from './client';
6
- import { getAccessToken, subscribeAuth } from './auth';
6
+ import { ensureValidToken, getAccessToken, getAuthSessionRevision, getRefreshToken, subscribeAuth } from './auth';
7
+ import { AuthRequiredError } from './errors';
7
8
 
8
9
  export type RealtimeLifecycleStatus =
9
10
  | 'disabled' | 'idle' | 'connecting' | 'authenticating' | 'ready'
@@ -106,6 +107,10 @@ export class RealtimeClient {
106
107
  private readonly topicTimeoutMs: number;
107
108
  private socket: RealtimeSocket | null = null;
108
109
  private token: string | null = null;
110
+ private authBinding: { unsubscribe: () => void } | null = null;
111
+ private authSessionRevision: number | null = null;
112
+ private preparing: object | null = null;
113
+ private authSent: RealtimeSocket | null = null;
109
114
  private enabled = false;
110
115
  private generation = 0;
111
116
  private reconnectAttempt = 0;
@@ -142,11 +147,44 @@ export class RealtimeClient {
142
147
  return () => this.statusListeners.delete(listener);
143
148
  };
144
149
 
150
+ /** Bind one session owner; renewal shares HTTP's single-flight auth gate. */
151
+ bindAuthSession(): () => void {
152
+ if (this.authBinding) throw new Error('Realtime client already has an auth session owner');
153
+ const binding = { unsubscribe: () => {} };
154
+ this.authBinding = binding;
155
+ this.stop(this.suspended ?? (this.enabled ? 'idle' : 'disabled'));
156
+ // Synchronous invalidation is essential: a React effect can run after
157
+ // an old refresh/handshake has completed for the previous account.
158
+ const syncSession = () => {
159
+ const revision = getAuthSessionRevision();
160
+ const replaced = this.authSessionRevision !== revision;
161
+ this.authSessionRevision = revision;
162
+ // A renewal already in flight when the server denied/revoked this
163
+ // connection is not an intentional replacement session. The fence
164
+ // also survives a renewal completed while no binding was mounted.
165
+ if (!replaced && this.suspended) {
166
+ this.token = getAccessToken();
167
+ return;
168
+ }
169
+ this.setToken(getAccessToken());
170
+ };
171
+ binding.unsubscribe = subscribeAuth(syncSession);
172
+ syncSession();
173
+ if (this.enabled) this.open();
174
+ return () => {
175
+ if (this.authBinding !== binding) return;
176
+ this.disconnect();
177
+ binding.unsubscribe();
178
+ this.authBinding = null;
179
+ };
180
+ }
181
+
145
182
  setEnabled(enabled: boolean): void {
146
183
  if (this.enabled === enabled) return;
147
184
  this.enabled = enabled;
148
185
  if (!enabled) this.stop('disabled');
149
- else if (this.token) this.open();
186
+ else if (this.suspended) this.setLifecycle(this.suspended);
187
+ else if (this.hasCredentials()) this.open();
150
188
  else this.setLifecycle('idle');
151
189
  }
152
190
 
@@ -156,7 +194,7 @@ export class RealtimeClient {
156
194
  this.token = token;
157
195
  this.suspended = null;
158
196
  this.stop(this.enabled && token ? 'idle' : this.enabled ? 'idle' : 'disabled');
159
- if (this.enabled && token) this.open();
197
+ if (this.enabled && this.hasCredentials()) this.open();
160
198
  }
161
199
 
162
200
  connect(token: string): void {
@@ -171,6 +209,8 @@ export class RealtimeClient {
171
209
 
172
210
  destroy(): void {
173
211
  this.disconnect();
212
+ this.authBinding?.unsubscribe();
213
+ this.authBinding = null;
174
214
  this.statusListeners.clear();
175
215
  this.topicListeners.clear();
176
216
  this.eventListeners.clear();
@@ -232,14 +272,18 @@ export class RealtimeClient {
232
272
  }
233
273
 
234
274
  nudge(): void {
235
- if (!this.enabled || !this.token || this.suspended || this.socket || this.retryTimer == null) return;
275
+ if (!this.enabled || !this.hasCredentials() || this.suspended || this.socket || this.retryTimer == null) return;
236
276
  if (this.clock.now() < this.extendedBackoffUntil) return;
237
277
  this.clearRetry();
238
278
  this.open();
239
279
  }
240
280
 
281
+ private hasCredentials(): boolean {
282
+ return Boolean(this.token || (this.authBinding && getRefreshToken()));
283
+ }
284
+
241
285
  private open(): void {
242
- if (!this.enabled || !this.token || this.suspended || this.socket) return;
286
+ if (!this.enabled || !this.hasCredentials() || this.suspended || this.socket || this.preparing) return;
243
287
  const run = this.generation;
244
288
  // Token refresh and provider remount use this same entry point; neither
245
289
  // may bypass a server admission deadline.
@@ -248,6 +292,47 @@ export class RealtimeClient {
248
292
  return;
249
293
  }
250
294
  this.setLifecycle('connecting');
295
+ this.prepareSession(run, null, () => this.openSocket(run));
296
+ }
297
+
298
+ private prepareSession(run: number, socket: RealtimeSocket | null, ready: () => void): void {
299
+ const binding = this.authBinding;
300
+ if (!binding) { ready(); return; }
301
+ if (this.preparing) return;
302
+ const attempt = {};
303
+ this.preparing = attempt;
304
+ const current = () => this.preparing === attempt && this.authBinding === binding
305
+ && this.enabled && !this.suspended && run === this.generation && this.socket === socket;
306
+ void Promise.resolve().then(() => {
307
+ if (current()) return ensureValidToken();
308
+ }).then(() => {
309
+ if (!current()) return;
310
+ this.preparing = null;
311
+ ready();
312
+ }).catch((error: unknown) => {
313
+ if (!current()) return;
314
+ this.preparing = null;
315
+ if (error instanceof AuthRequiredError) this.suspend('auth-failed', 4003, 'Authentication failed');
316
+ else this.retryAuthentication(run, socket);
317
+ });
318
+ }
319
+
320
+ private retryAuthentication(run: number, socket: RealtimeSocket | null): void {
321
+ this.preparing = null;
322
+ this.authSent = null;
323
+ this.socket = null;
324
+ this.clearHeartbeat();
325
+ this.resetTopicConnectionState();
326
+ this.scheduleReconnect(run, 0);
327
+ socket?.close(1000, 'Session renewal pending');
328
+ }
329
+
330
+ private openSocket(run: number): void {
331
+ // A renewal may have completed while an admission cooldown was set.
332
+ if (this.clock.now() < this.extendedBackoffUntil) {
333
+ this.scheduleReconnect(run, 0);
334
+ return;
335
+ }
251
336
  let socket: RealtimeSocket;
252
337
  try {
253
338
  socket = this.socketFactory(this.url);
@@ -267,6 +352,8 @@ export class RealtimeClient {
267
352
  socket.onerror = () => { /* close is the single retry path */ };
268
353
  socket.onclose = (event) => {
269
354
  if (!this.isCurrent(run, socket)) return;
355
+ this.preparing = null;
356
+ this.authSent = null;
270
357
  this.socket = null;
271
358
  this.clearHeartbeat();
272
359
  this.resetTopicConnectionState();
@@ -283,12 +370,16 @@ export class RealtimeClient {
283
370
  if (!frame || typeof frame.type !== 'string') return;
284
371
 
285
372
  if (frame.type === 'auth_required') {
286
- if (this.lifecycle !== 'authenticating' || !this.token) return;
287
- socket.send(JSON.stringify({ type: 'authenticate', token: this.token, prefix: 'bearer' }));
373
+ if (this.lifecycle !== 'authenticating' || !this.token || this.authSent === socket) return;
374
+ this.prepareSession(run, socket, () => {
375
+ // Latch before send: a transport may answer synchronously.
376
+ this.authSent = socket;
377
+ socket.send(JSON.stringify({ type: 'authenticate', token: this.token, prefix: 'bearer' }));
378
+ });
288
379
  return;
289
380
  }
290
381
  if (frame.type === 'auth_success') {
291
- if (this.lifecycle !== 'authenticating') return;
382
+ if (this.lifecycle !== 'authenticating' || this.authSent !== socket) return;
292
383
  this.reconnectAttempt = 0;
293
384
  this.extendedBackoffUntil = 0;
294
385
  this.automaticUserTopic = (typeof frame.user_type === 'string' && (typeof frame.user_id === 'number' || typeof frame.user_id === 'string'))
@@ -325,6 +416,8 @@ export class RealtimeClient {
325
416
  // Fence queued frames before closing: late auth_success must not
326
417
  // clear the deadline or make this refused socket ready.
327
418
  this.extendedBackoffUntil = this.clock.now() + 60_000;
419
+ this.preparing = null;
420
+ this.authSent = null;
328
421
  this.socket = null;
329
422
  this.clearHeartbeat();
330
423
  this.resetTopicConnectionState();
@@ -335,7 +428,11 @@ export class RealtimeClient {
335
428
  }
336
429
  if (frame.type === 'error') {
337
430
  if (this.lifecycle === 'authenticating') {
338
- this.suspend('auth-failed', 4003, 'Authentication failed');
431
+ // The server's admission timer can expire while renewal is
432
+ // pending. No credential was rejected in this specific case.
433
+ if (frame.message === 'Authentication timeout' && this.authBinding && this.authSent !== socket) {
434
+ this.retryAuthentication(run, socket);
435
+ } else this.suspend('auth-failed', 4003, 'Authentication failed');
339
436
  } else if (this.topicOperation) {
340
437
  this.denyCurrentTopic();
341
438
  } else {
@@ -492,6 +589,9 @@ export class RealtimeClient {
492
589
 
493
590
  private suspend(reason: 'auth-failed' | 'forced-disconnect', code: number, message: string): void {
494
591
  this.suspended = reason;
592
+ this.preparing = null;
593
+ this.authSent = null;
594
+ this.clearRetry();
495
595
  this.setLifecycle(reason);
496
596
  const socket = this.socket;
497
597
  this.socket = null;
@@ -502,6 +602,8 @@ export class RealtimeClient {
502
602
 
503
603
  private stop(status: RealtimeLifecycleStatus): void {
504
604
  this.generation += 1;
605
+ this.preparing = null;
606
+ this.authSent = null;
505
607
  this.clearRetry();
506
608
  this.clearHeartbeat();
507
609
  this.resetTopicConnectionState();
@@ -561,8 +663,7 @@ export type RealtimeProviderProps = PropsWithChildren<{
561
663
 
562
664
  export function RealtimeProvider({ children, enabled = true, client, socketFactory, origin }: RealtimeProviderProps) {
563
665
  const owned = useMemo(() => client ?? new RealtimeClient({ socketFactory, origin }), [client, socketFactory, origin]);
564
- const token = useSyncExternalStore(subscribeAuth, getAccessToken, getAccessToken);
565
- useEffect(() => { owned.setToken(token); }, [owned, token]);
666
+ useEffect(() => owned.bindAuthSession(), [owned]);
566
667
  useEffect(() => {
567
668
  owned.setEnabled(enabled);
568
669
  return () => owned.setEnabled(false);
@@ -247,3 +247,18 @@ button.cprog { cursor: pointer; font: inherit; }
247
247
 
248
248
  /* Settings dialog fields */
249
249
  .mmw-settings { display: flex; flex-direction: column; gap: 12px; }
250
+
251
+ /* Shared MetricsChartView also lives in narrow cards inside wide pages. */
252
+ .chart-panel { container-type: inline-size; min-width: 0; }
253
+ .chart-panel .chart-error { flex-wrap: wrap; overflow-wrap: anywhere; }
254
+ .chart-range-dialog { container-type: inline-size; }
255
+ @container (max-width: 560px) {
256
+ .chart-panel .gran-toggle { display: none; }
257
+ .chart-panel .gran-select { display: inline-block; }
258
+ }
259
+ @container (max-width: 500px) {
260
+ .chart-range-dialog .mojo-daterange-with-presets { grid-template-columns: minmax(0, 1fr); }
261
+ .chart-range-dialog .mojo-calendar-presets { flex-direction: row; flex-wrap: wrap; border-right: 0; border-bottom: 1px solid var(--line); }
262
+ .chart-range-dialog .mojo-calendar-presets-eyebrow { width: 100%; }
263
+ .chart-range-dialog .mojo-calendar-presets-divider { display: none; }
264
+ }
package/src/ui/modal.tsx CHANGED
@@ -113,9 +113,12 @@ export interface ModalOptions {
113
113
  flush?: boolean;
114
114
  /** Guards Escape/backdrop dismissal. Programmatic close still succeeds. */
115
115
  canDismiss?: () => boolean;
116
+ /** Abort removes this entry immediately, even without a mounted ModalHost. */
117
+ signal?: AbortSignal;
116
118
  }
117
119
 
118
120
  function open<T>(render: (close: (value: T) => void) => ReactNode, opts: ModalOptions = {}): Promise<T | null> {
121
+ if (opts.signal?.aborted) return Promise.resolve(null);
119
122
  wireAuth();
120
123
  return new Promise<T | null>((resolve) => {
121
124
  const id = nextId++;
@@ -123,10 +126,13 @@ function open<T>(render: (close: (value: T) => void) => ReactNode, opts: ModalOp
123
126
  const close = (value: unknown) => {
124
127
  if (settled) return;
125
128
  settled = true;
129
+ opts.signal?.removeEventListener('abort', abort);
126
130
  stack = stack.filter((m) => m.id !== id);
127
131
  emit();
128
132
  resolve(value as T | null);
129
133
  };
134
+ const abort = () => close(null);
135
+ opts.signal?.addEventListener('abort', abort, { once: true });
130
136
  stack = [...stack, {
131
137
  id,
132
138
  variant: 'modal',