@celestia-island/hikari 0.55.144 → 0.55.146

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.144",
3
+ "version": "0.55.146",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -194,7 +194,7 @@ describe("HkDatePicker", () => {
194
194
  });
195
195
 
196
196
  it("disables days outside the inclusive min/max bounds", async () => {
197
- const p = mountPicker({ modelValue: "2026-08-16", min: "2026-08-10", max: "2026-08-20" });
197
+ const p = mountPicker({ modelValue: "2026-08-16", min: "2026-08-10", max: "2026-08-20", nativeOnMobile: true });
198
198
  openViaEnter(p);
199
199
  await nextTick();
200
200
  const byDay = new Map(
@@ -453,22 +453,12 @@ describe("HkDatePicker", () => {
453
453
  expect(stage?.querySelectorAll<HTMLButtonElement>(".hk-dp-cell:not([data-variant])").length).toBe(42);
454
454
  });
455
455
 
456
- // ── Mobile pass-through to the native OS control ────────────────
456
+ // ── Mobile: the CALENDAR is the default; the native input is the
457
+ // ── explicit opt-out (2026-10-11 direction: no native controls) ──
457
458
 
458
- it("renders a native date input instead of the calendar on mobile", () => {
459
+ it("keeps the calendar on mobile by default (no native input)", async () => {
459
460
  useMobileViewport();
460
461
  const p = mountPicker({ modelValue: "2026-08-16" });
461
- const input = nativeInput(p.container);
462
- expect(input).not.toBeNull();
463
- expect(input?.type).toBe("date");
464
- expect(input?.value).toBe("2026-08-16");
465
- expect(p.container.querySelector(".hk-dp-trigger")).toBeNull();
466
- expect(p.container.querySelector(".hk-dp-panel")).toBeNull();
467
- });
468
-
469
- it("keeps the custom calendar on mobile when nativeOnMobile is false", async () => {
470
- useMobileViewport();
471
- const p = mountPicker({ modelValue: "2026-08-16", nativeOnMobile: false });
472
462
  expect(p.container.querySelector(".hk-dp-trigger")).not.toBeNull();
473
463
  expect(nativeInput(p.container)).toBeNull();
474
464
  openViaEnter(p);
@@ -479,17 +469,28 @@ describe("HkDatePicker", () => {
479
469
  expect(panel()?.classList.contains("is-touch")).toBe(true);
480
470
  });
481
471
 
482
- it("passes min/max through to the native input", () => {
472
+ it("renders a native date input on mobile only when nativeOnMobile opts in", () => {
473
+ useMobileViewport();
474
+ const p = mountPicker({ modelValue: "2026-08-16", nativeOnMobile: true });
475
+ const input = nativeInput(p.container);
476
+ expect(input).not.toBeNull();
477
+ expect(input?.type).toBe("date");
478
+ expect(input?.value).toBe("2026-08-16");
479
+ expect(p.container.querySelector(".hk-dp-trigger")).toBeNull();
480
+ expect(p.container.querySelector(".hk-dp-panel")).toBeNull();
481
+ });
482
+
483
+ it("passes min/max through to the native input (explicit opt-in)", () => {
483
484
  useMobileViewport();
484
- const p = mountPicker({ modelValue: "2026-08-16", min: "2026-08-10", max: "2026-08-20" });
485
+ const p = mountPicker({ modelValue: "2026-08-16", min: "2026-08-10", max: "2026-08-20", nativeOnMobile: true });
485
486
  const input = nativeInput(p.container);
486
487
  expect(input?.min).toBe("2026-08-10");
487
488
  expect(input?.max).toBe("2026-08-20");
488
489
  });
489
490
 
490
- it("native input edits emit the ISO date and clearing emits null", async () => {
491
+ it("native input edits emit the ISO date and clearing emits null (explicit opt-in)", async () => {
491
492
  useMobileViewport();
492
- const p = mountPicker({ modelValue: "2026-08-16" });
493
+ const p = mountPicker({ modelValue: "2026-08-16", nativeOnMobile: true });
493
494
  const input = nativeInput(p.container);
494
495
  input!.value = "2026-08-20";
495
496
  input!.dispatchEvent(new Event("input", { bubbles: true }));
@@ -503,9 +504,9 @@ describe("HkDatePicker", () => {
503
504
  expect(p.emitted).toEqual(["2026-08-20", null]);
504
505
  });
505
506
 
506
- it("native input edits outside the bounds are rejected and re-synced", async () => {
507
+ it("native input edits outside the bounds are rejected and re-synced (explicit opt-in)", async () => {
507
508
  useMobileViewport();
508
- const p = mountPicker({ modelValue: "2026-08-16", max: "2026-08-20" });
509
+ const p = mountPicker({ modelValue: "2026-08-16", max: "2026-08-20", nativeOnMobile: true });
509
510
  const input = nativeInput(p.container);
510
511
  input!.value = "2026-09-01";
511
512
  input!.dispatchEvent(new Event("input", { bubbles: true }));
@@ -73,8 +73,12 @@ export default defineComponent({
73
73
  min: { type: String as PropType<string | undefined>, default: undefined },
74
74
  /** Inclusive upper bound as an ISO date; later days render disabled. */
75
75
  max: { type: String as PropType<string | undefined>, default: undefined },
76
- /** Render the OS native `<input type="date">` on touch-sized viewports. */
77
- nativeOnMobile: { type: Boolean, default: true },
76
+ /** Render the OS native `<input type="date">` on touch-sized viewports.
77
+ * DEFAULTS FALSE since 2026-10-11 (user direction: no native controls
78
+ * anywhere) — the calendar serves phones as a bottom sheet with the
79
+ * enlarged touch chrome. The prop stays for one-off hosts that truly
80
+ * want the OS picker; new callers should not set it. */
81
+ nativeOnMobile: { type: Boolean, default: false },
78
82
  },
79
83
  emits: {
80
84
  "update:modelValue": (_value: string | null) => true,
@@ -324,11 +324,12 @@ describe("HkDateTimePicker", () => {
324
324
  expect(document.querySelectorAll(".hk-dtp-popup .hk-dtp-cell").length).toBe(42);
325
325
  });
326
326
 
327
- // ── Mobile pass-through to the native OS control ────────────────
327
+ // ── Mobile: the calendar is the default; native is the explicit ──
328
+ // ── opt-out (2026-10-11 direction: no native controls) ───────────
328
329
 
329
- it("renders a native datetime-local input instead of the calendar on mobile", () => {
330
+ it("renders a native datetime-local input on mobile only when nativeOnMobile opts in", () => {
330
331
  useMobileViewport();
331
- const p = mountPicker();
332
+ const p = mountPicker({ nativeOnMobile: true });
332
333
  const input = nativeInput(p.container);
333
334
  expect(input).not.toBeNull();
334
335
  expect(input?.type).toBe("datetime-local");
@@ -337,9 +338,9 @@ describe("HkDateTimePicker", () => {
337
338
  expect(p.container.querySelector(".hk-dtp-grid")).toBeNull();
338
339
  });
339
340
 
340
- it("uses a native date input when showTime is false", () => {
341
+ it("uses a native date input when showTime is false (explicit opt-in)", () => {
341
342
  useMobileViewport();
342
- const p = mountPicker({ showTime: false });
343
+ const p = mountPicker({ showTime: false, nativeOnMobile: true });
343
344
  const input = nativeInput(p.container);
344
345
  expect(input?.type).toBe("date");
345
346
  expect(input?.value).toBe(isoDay(BASE));
@@ -347,15 +348,15 @@ describe("HkDateTimePicker", () => {
347
348
 
348
349
  it("passes min/max through to the native input in its wire format", () => {
349
350
  useMobileViewport();
350
- const p = mountPicker({ min: dayOf(10, 8, 0), max: dayOf(20, 18, 30) });
351
+ const p = mountPicker({ min: dayOf(10, 8, 0), max: dayOf(20, 18, 30), nativeOnMobile: true });
351
352
  const input = nativeInput(p.container);
352
353
  expect(input?.min).toBe(isoMinute(dayOf(10, 8, 0)));
353
354
  expect(input?.max).toBe(isoMinute(dayOf(20, 18, 30)));
354
355
  });
355
356
 
356
- it("keeps the custom calendar on mobile when nativeOnMobile is false", () => {
357
+ it("keeps the custom calendar on mobile by default", () => {
357
358
  useMobileViewport();
358
- const p = mountPicker({ nativeOnMobile: false });
359
+ const p = mountPicker();
359
360
  expect(p.container.querySelector(".hk-dtp")).not.toBeNull();
360
361
  expect(dayCells().length).toBe(42);
361
362
  expect(nativeInput(p.container)).toBeNull();
@@ -365,7 +366,7 @@ describe("HkDateTimePicker", () => {
365
366
 
366
367
  it("native input edits update the model as a local-time Date", async () => {
367
368
  useMobileViewport();
368
- const p = mountPicker();
369
+ const p = mountPicker({ nativeOnMobile: true });
369
370
  const input = nativeInput(p.container);
370
371
  input!.value = isoMinute(dayOf(20, 14, 5));
371
372
  input!.dispatchEvent(new Event("input", { bubbles: true }));
@@ -377,7 +378,7 @@ describe("HkDateTimePicker", () => {
377
378
 
378
379
  it("native date edits preserve the clock time when showTime is false", async () => {
379
380
  useMobileViewport();
380
- const p = mountPicker({ showTime: false });
381
+ const p = mountPicker({ showTime: false, nativeOnMobile: true });
381
382
  const input = nativeInput(p.container);
382
383
  input!.value = isoDay(dayOf(20));
383
384
  input!.dispatchEvent(new Event("input", { bubbles: true }));
@@ -387,7 +388,7 @@ describe("HkDateTimePicker", () => {
387
388
 
388
389
  it("native input edits outside the bounds are rejected and re-synced", async () => {
389
390
  useMobileViewport();
390
- const p = mountPicker({ max: dayOf(20, 23, 59) });
391
+ const p = mountPicker({ max: dayOf(20, 23, 59), nativeOnMobile: true });
391
392
  const input = nativeInput(p.container);
392
393
  // Next month, past the max bound.
393
394
  input!.value = isoMinute(new Date(BASE.getFullYear(), BASE.getMonth() + 1, 1, 10, 0));
@@ -399,7 +400,7 @@ describe("HkDateTimePicker", () => {
399
400
 
400
401
  it("clearing the native input falls back to the model value", async () => {
401
402
  useMobileViewport();
402
- const p = mountPicker();
403
+ const p = mountPicker({ nativeOnMobile: true });
403
404
  const input = nativeInput(p.container);
404
405
  input!.value = "";
405
406
  input!.dispatchEvent(new Event("input", { bubbles: true }));
@@ -408,9 +409,9 @@ describe("HkDateTimePicker", () => {
408
409
  expect(input?.value).toBe(isoMinute(BASE));
409
410
  });
410
411
 
411
- it("native input replaces even the popup chrome on mobile", () => {
412
+ it("native input replaces even the popup chrome on mobile (explicit opt-in)", () => {
412
413
  useMobileViewport();
413
- const p = mountPicker({ mode: "popup", confirmLabel: "Confirm" });
414
+ const p = mountPicker({ mode: "popup", confirmLabel: "Confirm", nativeOnMobile: true });
414
415
  expect(nativeInput(p.container)).not.toBeNull();
415
416
  expect(p.container.querySelector(".hk-dtp-trigger")).toBeNull();
416
417
  });
@@ -95,7 +95,7 @@ export default defineComponent({
95
95
  confirmLabel: { type: String, default: undefined },
96
96
  showTime: { type: Boolean, default: true },
97
97
  /** Render the OS native `<input type="datetime-local">` on touch-sized viewports. */
98
- nativeOnMobile: { type: Boolean, default: true },
98
+ nativeOnMobile: { type: Boolean, default: false }, // 2026-10-11: no native controls — the picker sheet serves phones
99
99
  },
100
100
  emits: {
101
101
  "update:modelValue": (_d: Date) => true,
@@ -99,6 +99,7 @@ export default defineComponent({
99
99
  anchorRef={anchorRef.value}
100
100
  placement={props.placement}
101
101
  offset={6}
102
+ sheetOnMobile
102
103
  title={label}
103
104
  >
104
105
  <div class="hk-filter-bar-panel" data-testid="hk-filter-bar-panel">
@@ -1,5 +1,6 @@
1
1
  import { computed, defineComponent, type PropType } from "vue";
2
2
 
3
+ import HkDatePicker from "./HkDatePicker";
3
4
  import HkInput from "./HkInput";
4
5
  import HkPopupSelect, { type HkPopupSelectOption } from "./HkPopupSelect";
5
6
  import { useI18n } from "../i18n/context";
@@ -168,9 +169,20 @@ export default defineComponent({
168
169
  disabled={props.disabled}
169
170
  options={selectOptions(field)}
170
171
  />
172
+ ) : field.kind === "date" ? (
173
+ // The calendar picker — never the OS native input
174
+ // (2026-10-11 direction); the wire format is the same
175
+ // ISO YYYY-MM-DD the native input spoke.
176
+ <HkDatePicker
177
+ modelValue={condition.value || null}
178
+ onUpdate:modelValue={(v: string | null) =>
179
+ emitCondition(field, { value: v ?? "" })
180
+ }
181
+ disabled={props.disabled}
182
+ />
171
183
  ) : (
172
184
  <HkInput
173
- type={field.kind === "date" ? "date" : "text"}
185
+ type="text"
174
186
  variant={field.kind === "number" ? "number" : "text"}
175
187
  modelValue={condition.value}
176
188
  onUpdate:modelValue={(v: string) =>
@@ -0,0 +1,179 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { createApp, defineComponent, h, type Ref } from "vue";
3
+
4
+ import { useConnectionProbe, type ProbeResult } from "./useConnectionProbe";
5
+
6
+ /** The probe is a mount-scoped composable (onMounted drives the first
7
+ * fetch); each test rides a minimal component harness. Fake timers own
8
+ * both the clock (latency, countdown ladder) and the interval bus. */
9
+
10
+ interface Deferred {
11
+ promise: Promise<Response>;
12
+ resolve: (r: Response) => void;
13
+ reject: (e: unknown) => void;
14
+ }
15
+
16
+ function deferredResponse(): Deferred {
17
+ let resolve!: (r: Response) => void;
18
+ let reject!: (e: unknown) => void;
19
+ const promise = new Promise<Response>((res, rej) => {
20
+ resolve = res;
21
+ reject = rej;
22
+ });
23
+ return { promise, resolve, reject };
24
+ }
25
+
26
+ function okResponse(): Response {
27
+ return { ok: true } as Response;
28
+ }
29
+
30
+ function mountProbe() {
31
+ let out!: { result: Ref<ProbeResult>; retryNow: () => void };
32
+ const app = createApp(
33
+ defineComponent({
34
+ setup() {
35
+ out = useConnectionProbe();
36
+ return () => h("div");
37
+ },
38
+ }),
39
+ );
40
+ const container = document.createElement("div");
41
+ document.body.appendChild(container);
42
+ app.mount(container);
43
+ return {
44
+ probe: () => out,
45
+ unmount: () => {
46
+ app.unmount();
47
+ container.remove();
48
+ },
49
+ };
50
+ }
51
+
52
+ afterEach(() => {
53
+ vi.useRealTimers();
54
+ vi.unstubAllGlobals();
55
+ document.body.innerHTML = "";
56
+ });
57
+
58
+ describe("useConnectionProbe continuous observation", () => {
59
+ it("keeps polling after a successful probe — parked-forever was the bug", async () => {
60
+ vi.useFakeTimers();
61
+ const fetchMock = vi.fn(() => Promise.resolve(okResponse()));
62
+ vi.stubGlobal("fetch", fetchMock);
63
+ const { probe, unmount } = mountProbe();
64
+ try {
65
+ await vi.advanceTimersByTimeAsync(0);
66
+ expect(fetchMock).toHaveBeenCalledTimes(1);
67
+ expect(probe().result.value.state).toBe("connected");
68
+
69
+ // The scheduled re-check fires 15s later — the light KEEPS
70
+ // observing instead of recording the initial verdict forever.
71
+ await vi.advanceTimersByTimeAsync(15_000);
72
+ expect(fetchMock, "healthy backend keeps being polled").toHaveBeenCalledTimes(2);
73
+ expect(probe().result.value.state).toBe("connected");
74
+ expect(probe().result.value.connected).toBe(true);
75
+ } finally {
76
+ unmount();
77
+ }
78
+ });
79
+
80
+ it("a healthy re-check stays GREEN in flight — no reconnecting blink, no flash", async () => {
81
+ vi.useFakeTimers();
82
+ const first = deferredResponse();
83
+ const second = deferredResponse();
84
+ // Route by call order: first mount fetch, then the scheduled re-check.
85
+ let calls = 0;
86
+ const fetchMock = vi.fn(() => {
87
+ calls += 1;
88
+ return calls === 1 ? first.promise : second.promise;
89
+ });
90
+ vi.stubGlobal("fetch", fetchMock);
91
+ const { probe, unmount } = mountProbe();
92
+ try {
93
+ first.resolve(okResponse());
94
+ await vi.advanceTimersByTimeAsync(0);
95
+ expect(probe().result.value.state).toBe("connected");
96
+
97
+ // Fire the scheduled re-check and let it hang mid-fetch: the OLD
98
+ // behavior flipped the state to "reconnecting" here (yellow blink
99
+ // + recovery flash every interval). Quiet re-checks must not.
100
+ await vi.advanceTimersByTimeAsync(15_000);
101
+ await Promise.resolve(); // enter the awaiting fetch
102
+ expect(fetchMock).toHaveBeenCalledTimes(2);
103
+ expect(probe().result.value.state, "in-flight re-check is quiet").toBe("connected");
104
+ expect(probe().result.value.connected).toBe(true);
105
+
106
+ second.resolve(okResponse());
107
+ await vi.advanceTimersByTimeAsync(0);
108
+ expect(probe().result.value.state).toBe("connected");
109
+ } finally {
110
+ unmount();
111
+ }
112
+ });
113
+
114
+ it("a re-check that fails drops into the retry ladder and recovers", async () => {
115
+ vi.useFakeTimers();
116
+ const fetchMock = vi.fn();
117
+ fetchMock.mockResolvedValueOnce(okResponse()); // mount: healthy
118
+ fetchMock.mockRejectedValueOnce(new Error("backend died")); // re-check: dead
119
+ fetchMock.mockResolvedValueOnce(okResponse()); // ladder retry: back
120
+ vi.stubGlobal("fetch", fetchMock);
121
+ const { probe, unmount } = mountProbe();
122
+ try {
123
+ await vi.advanceTimersByTimeAsync(0);
124
+ expect(probe().result.value.state).toBe("connected");
125
+
126
+ await vi.advanceTimersByTimeAsync(15_000);
127
+ expect(fetchMock).toHaveBeenCalledTimes(2);
128
+ // The failure is REAL and must show: red, disconnected, ladder
129
+ // armed (attempt 1 of the retryTotal, countdown to the next try).
130
+ expect(probe().result.value.connected).toBe(false);
131
+ expect(probe().result.value.state).toBe("disconnected");
132
+ expect(probe().result.value.attemptNumber).toBe(1);
133
+ expect(probe().result.value.countdown).toBeGreaterThan(0);
134
+
135
+ // Ladder retry succeeds: green again, and the attempt counter
136
+ // RESET — a lifetime odometer would read "Retrying N/3" with N
137
+ // grown by every healthy poll.
138
+ await vi.advanceTimersByTimeAsync(15_000);
139
+ expect(fetchMock).toHaveBeenCalledTimes(3);
140
+ expect(probe().result.value.connected).toBe(true);
141
+ expect(probe().result.value.state).toBe("connected");
142
+ expect(probe().result.value.attemptNumber).toBe(0);
143
+ } finally {
144
+ unmount();
145
+ }
146
+ });
147
+
148
+ it("stops observing once unmounted", async () => {
149
+ vi.useFakeTimers();
150
+ const fetchMock = vi.fn(() => Promise.resolve(okResponse()));
151
+ vi.stubGlobal("fetch", fetchMock);
152
+ const { probe, unmount } = mountProbe();
153
+ await vi.advanceTimersByTimeAsync(0);
154
+ await vi.advanceTimersByTimeAsync(15_000);
155
+ expect(fetchMock).toHaveBeenCalledTimes(2);
156
+ unmount();
157
+ await vi.advanceTimersByTimeAsync(45_000);
158
+ expect(fetchMock, "no polls after unmount").toHaveBeenCalledTimes(2);
159
+ expect(probe().result.value.state).toBe("connected");
160
+ });
161
+
162
+ it("an in-flight probe settling AFTER unmount arms no zombie loop", async () => {
163
+ vi.useFakeTimers();
164
+ const hang = deferredResponse();
165
+ const fetchMock = vi.fn(() => hang.promise);
166
+ vi.stubGlobal("fetch", fetchMock);
167
+ const { probe, unmount } = mountProbe();
168
+ // The race (R2 catch, 2026-10-11): the mount fetch is still in
169
+ // flight when the component unmounts; its late SUCCESS used to arm
170
+ // a fresh 15s slot on a dead component — a poll loop that never
171
+ // stopped (4+ fetches per 45s, forever). The disposed guard must
172
+ // swallow the arming; the late state write itself is harmless.
173
+ unmount();
174
+ hang.resolve(okResponse());
175
+ await vi.advanceTimersByTimeAsync(60_000);
176
+ expect(fetchMock, "no post-unmount polls, ever").toHaveBeenCalledTimes(1);
177
+ expect(probe().result.value.connected, "late write is harmless").toBe(true);
178
+ });
179
+ });
@@ -78,23 +78,38 @@ export function useConnectionProbe(): {
78
78
  let tickHandle: IntervalHandle | null = null;
79
79
  let nextAttemptAt = 0;
80
80
  let consecutiveFailures = 0;
81
+ // Set at unmount, read by schedulePoll: an in-flight probe resolving
82
+ // AFTER unmount must not arm a fresh slot — that race resurrected a
83
+ // 15s zombie loop polling forever on a dead component (R2 catch,
84
+ // 2026-10-11). Late state writes into the dead ref are harmless;
85
+ // late ARMING is not.
86
+ let disposed = false;
81
87
 
82
88
  function applyCountdown(): void {
83
89
  const seconds = Math.max(0, Math.ceil((nextAttemptAt - Date.now()) / 1000));
84
90
  result.value = { ...result.value, countdown: seconds };
85
91
  }
86
92
 
87
- async function probeOnce(): Promise<void> {
93
+ async function probeOnce(opts?: { quiet?: boolean }): Promise<void> {
88
94
  pollHandle?.disconnect();
89
95
  pollHandle = null;
96
+ const wasConnected = result.value.connected;
97
+ // Quiet re-checks (the scheduled poll while healthy) keep the light
98
+ // GREEN through the fetch: flipping every healthy re-check through
99
+ // "reconnecting" would blink the pill and re-fire the recovery flash
100
+ // once per interval. A quiet re-check that FAILS falls through to
101
+ // the ladder below — that transition is real and must show.
102
+ const quiet = opts?.quiet === true && wasConnected;
90
103
  const attempt = result.value.attemptNumber + 1;
91
- result.value = {
92
- ...result.value,
93
- state: result.value.connected ? "reconnecting" : "connecting",
94
- attemptNumber: attempt,
95
- retryCount: attempt,
96
- countdown: 0,
97
- };
104
+ if (!quiet) {
105
+ result.value = {
106
+ ...result.value,
107
+ state: wasConnected ? "reconnecting" : "connecting",
108
+ attemptNumber: attempt,
109
+ retryCount: attempt,
110
+ countdown: 0,
111
+ };
112
+ }
98
113
  const started = Date.now();
99
114
  try {
100
115
  const resp = await fetch(endpointUrl(), {
@@ -104,12 +119,26 @@ export function useConnectionProbe(): {
104
119
  const latencyMs = Date.now() - started;
105
120
  if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
106
121
  consecutiveFailures = 0;
122
+ nextAttemptAt = 0;
107
123
  result.value = {
108
124
  ...result.value,
109
125
  connected: true,
110
126
  state: "connected",
111
127
  latencyMs,
128
+ // The attempt counter is LADDER state, not a lifetime odometer:
129
+ // a healthy probe zeroes it so the next outage's ladder reads
130
+ // "Retrying 1/3" instead of an ever-growing number. Continuous
131
+ // polling made this matter — the counter used to stop moving
132
+ // once connected, so the creep was invisible.
133
+ attemptNumber: 0,
134
+ retryCount: 0,
135
+ countdown: 0,
112
136
  };
137
+ // Continuous observation (2026-10-11 auth-surface report): the
138
+ // light must FOLLOW the backend, not just record the initial
139
+ // verdict. Parked-forever-on-success meant a backend dying later
140
+ // left a permanently green light on sessionless surfaces.
141
+ schedulePoll(true);
113
142
  } catch {
114
143
  consecutiveFailures += 1;
115
144
  const failed = consecutiveFailures >= result.value.retryTotal;
@@ -118,6 +147,8 @@ export function useConnectionProbe(): {
118
147
  connected: false,
119
148
  state: failed ? "failed" : "disconnected",
120
149
  latencyMs: null,
150
+ attemptNumber: attempt,
151
+ retryCount: attempt,
121
152
  };
122
153
  nextAttemptAt = Date.now() + DEFAULT_POLL_MS;
123
154
  applyCountdown();
@@ -125,15 +156,21 @@ export function useConnectionProbe(): {
125
156
  }
126
157
  }
127
158
 
128
- function schedulePoll(): void {
129
- // Visibility-aware one-shot: the 15s retry window holds while the
130
- // page is hidden instead of burning down on a throttled background
131
- // timer (intervalBus — this is its designed case: data polling, not
132
- // animation).
159
+ function schedulePoll(quiet = false): void {
160
+ // The zombie guard: an in-flight probe settling after unmount lands
161
+ // here with nothing left to serve — arming would loop forever on a
162
+ // dead component. Both call sites (success re-check AND the failure
163
+ // ladder) route through this single gate.
164
+ if (disposed) return;
165
+ // Visibility-aware one-shot: the 15s window holds while the page is
166
+ // hidden instead of burning down on a throttled background timer
167
+ // (intervalBus — this is its designed case: data polling, not
168
+ // animation). Retry-ladder hops stay loud; the success-path
169
+ // re-check passes `quiet` so a healthy poll never blinks the light.
133
170
  pollHandle?.disconnect();
134
171
  pollHandle = scheduleIntervalAfter(() => {
135
172
  pollHandle = null;
136
- void probeOnce();
173
+ void probeOnce({ quiet });
137
174
  }, DEFAULT_POLL_MS);
138
175
  }
139
176
 
@@ -151,6 +188,9 @@ export function useConnectionProbe(): {
151
188
  onUnmounted(() => {
152
189
  liveProbes = Math.max(0, liveProbes - 1);
153
190
  probeRuntime?.pulse({ liveProbes });
191
+ // Disarm FIRST: an in-flight probe's late success must find the
192
+ // guard already up when it reaches schedulePoll.
193
+ disposed = true;
154
194
  pollHandle?.disconnect();
155
195
  pollHandle = null;
156
196
  tickHandle?.disconnect();
@@ -0,0 +1,128 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { createApp, defineComponent, h, type Ref } from "vue";
3
+
4
+ import { useEngineHealth, type EngineHealth } from "./useEngineHealth";
5
+
6
+ /** useEngineHealth is mount-scoped (onMounted drives the first fetch);
7
+ * each test rides a minimal component harness. Fake timers own the
8
+ * 30s refresh loop. */
9
+
10
+ interface Deferred {
11
+ promise: Promise<Response>;
12
+ resolve: (r: Response) => void;
13
+ }
14
+
15
+ function deferredJsonResponse(): Deferred {
16
+ let resolve!: (r: Response) => void;
17
+ const promise = new Promise<Response>((res) => {
18
+ resolve = res;
19
+ });
20
+ return { promise, resolve };
21
+ }
22
+
23
+ function okBody(body: Record<string, unknown>): Response {
24
+ return { ok: true, json: () => Promise.resolve(body) } as unknown as Response;
25
+ }
26
+
27
+ function mountHealth() {
28
+ let out!: {
29
+ health: Ref<EngineHealth | null>;
30
+ loading: Ref<boolean>;
31
+ error: Ref<string | null>;
32
+ refresh: () => Promise<void>;
33
+ };
34
+ const app = createApp(
35
+ defineComponent({
36
+ setup() {
37
+ out = useEngineHealth();
38
+ return () => h("div");
39
+ },
40
+ }),
41
+ );
42
+ const container = document.createElement("div");
43
+ document.body.appendChild(container);
44
+ app.mount(container);
45
+ return {
46
+ api: () => out,
47
+ unmount: () => {
48
+ app.unmount();
49
+ container.remove();
50
+ },
51
+ };
52
+ }
53
+
54
+ afterEach(() => {
55
+ vi.useRealTimers();
56
+ vi.unstubAllGlobals();
57
+ document.body.innerHTML = "";
58
+ });
59
+
60
+ describe("useEngineHealth refresh loop", () => {
61
+ it("recovers the engine row after a transient mount-time failure", async () => {
62
+ vi.useFakeTimers();
63
+ const fetchMock = vi.fn();
64
+ fetchMock.mockRejectedValueOnce(new Error("transient")); // mount: fails
65
+ fetchMock.mockResolvedValueOnce(okBody({ engine_version: "9.8.7", build_hash: "abc1234" }));
66
+ vi.stubGlobal("fetch", fetchMock);
67
+ const { api, unmount } = mountHealth();
68
+ try {
69
+ await vi.advanceTimersByTimeAsync(0);
70
+ expect(api().health.value, "mount-time failure blanks the row").toBeNull();
71
+ expect(api().error.value).toBeTruthy();
72
+
73
+ // The one-shot era left this row dead for the page's whole life;
74
+ // the 30s loop revives it (2026-10-11 auth-surface report).
75
+ await vi.advanceTimersByTimeAsync(30_000);
76
+ expect(fetchMock).toHaveBeenCalledTimes(2);
77
+ expect(api().health.value?.engineVersion).toBe("9.8.7");
78
+ expect(api().health.value?.engineBuildHash).toBe("abc1234");
79
+ expect(api().error.value).toBeNull();
80
+ } finally {
81
+ unmount();
82
+ }
83
+ });
84
+
85
+ it("keeps the row fresh on cadence and stops on unmount", async () => {
86
+ vi.useFakeTimers();
87
+ const fetchMock = vi.fn(() => Promise.resolve(okBody({ engine_version: "1.0.0" })));
88
+ vi.stubGlobal("fetch", fetchMock);
89
+ const { unmount } = mountHealth();
90
+ await vi.advanceTimersByTimeAsync(0);
91
+ expect(fetchMock).toHaveBeenCalledTimes(1);
92
+ await vi.advanceTimersByTimeAsync(30_000);
93
+ expect(fetchMock, "cadence refresh fires").toHaveBeenCalledTimes(2);
94
+ await vi.advanceTimersByTimeAsync(30_000);
95
+ expect(fetchMock).toHaveBeenCalledTimes(3);
96
+ unmount();
97
+ await vi.advanceTimersByTimeAsync(90_000);
98
+ expect(fetchMock, "no refreshes after unmount").toHaveBeenCalledTimes(3);
99
+ });
100
+
101
+ it("never stacks requests — a slow refresh skips the next beat", async () => {
102
+ vi.useFakeTimers();
103
+ const slow = deferredJsonResponse();
104
+ const fetchMock = vi.fn();
105
+ fetchMock.mockResolvedValueOnce(okBody({ engine_version: "1.0.0" })); // mount
106
+ fetchMock.mockImplementationOnce(() => slow.promise); // t+30s: hangs
107
+ vi.stubGlobal("fetch", fetchMock);
108
+ const { api, unmount } = mountHealth();
109
+ try {
110
+ await vi.advanceTimersByTimeAsync(0);
111
+ await vi.advanceTimersByTimeAsync(30_000);
112
+ expect(fetchMock).toHaveBeenCalledTimes(2);
113
+ expect(api().loading.value).toBe(true);
114
+
115
+ // The t+60s beat arrives while the t+30s refresh still hangs — it
116
+ // must be skipped, not stacked.
117
+ await vi.advanceTimersByTimeAsync(30_000);
118
+ expect(fetchMock, "in-flight refresh holds the beat").toHaveBeenCalledTimes(2);
119
+
120
+ slow.resolve(okBody({ engine_version: "2.0.0" }));
121
+ await vi.advanceTimersByTimeAsync(0);
122
+ expect(api().loading.value).toBe(false);
123
+ expect(api().health.value?.engineVersion).toBe("2.0.0");
124
+ } finally {
125
+ unmount();
126
+ }
127
+ });
128
+ });
@@ -1,4 +1,6 @@
1
- import { onMounted, ref, type Ref } from "vue";
1
+ import { onMounted, onUnmounted, ref, type Ref } from "vue";
2
+
3
+ import { scheduleInterval, type IntervalHandle } from "../runtime/intervalBus";
2
4
 
3
5
  /** Inlined subset of the `/api/health` network payload — kept local so
4
6
  * hikari stays dependency-free of protocol packages. */
@@ -24,9 +26,18 @@ export interface EngineHealth {
24
26
  network: NetworkInfo | null;
25
27
  }
26
28
 
29
+ /** Mounted refresh cadence (visibility-aware via the interval bus — a
30
+ * hidden tab parks instead of burning radio). 30s: engine identity is
31
+ * not latency-critical, but a transient mount-time failure must not
32
+ * blank the engine row for the page's whole life (2026-10-11
33
+ * auth-surface report — the one-shot fetch left a dead row forever). */
34
+ const REFRESH_MS = 30_000;
35
+
27
36
  /**
28
- * Fetch `/api/health` once on mount and expose the engine/network subset.
29
- * Replaces per-app hand-rolled health fetches. Call `refresh()` to re-poll.
37
+ * Fetch `/api/health` on mount and keep it fresh on a visibility-aware
38
+ * 30s loop; expose the engine/network subset. Replaces per-app
39
+ * hand-rolled health fetches. Call `refresh()` to force an immediate
40
+ * re-poll outside the cadence.
30
41
  *
31
42
  * @param baseUrl Optional API origin (defaults to same-origin "").
32
43
  */
@@ -39,6 +50,7 @@ export function useEngineHealth(baseUrl?: string): {
39
50
  const health = ref<EngineHealth | null>(null);
40
51
  const loading = ref(false);
41
52
  const error = ref<string | null>(null);
53
+ let tickHandle: IntervalHandle | null = null;
42
54
 
43
55
  async function refresh(): Promise<void> {
44
56
  loading.value = true;
@@ -63,7 +75,19 @@ export function useEngineHealth(baseUrl?: string): {
63
75
  }
64
76
  }
65
77
 
66
- onMounted(() => { void refresh(); });
78
+ onMounted(() => {
79
+ void refresh();
80
+ tickHandle = scheduleInterval(() => {
81
+ // Never stack requests on a slow endpoint — skip the beat while a
82
+ // refresh is still in flight.
83
+ if (!loading.value) void refresh();
84
+ }, REFRESH_MS);
85
+ });
86
+
87
+ onUnmounted(() => {
88
+ tickHandle?.disconnect();
89
+ tickHandle = null;
90
+ });
67
91
 
68
92
  return { health, loading, error, refresh };
69
93
  }