@celestia-island/hikari 0.40.13 → 0.40.14

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.40.13",
3
+ "version": "0.40.14",
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",
@@ -0,0 +1,179 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { createApp, defineComponent, h, nextTick, ref } from "vue";
6
+
7
+ import HkModal from "./HkModal";
8
+
9
+ const mounts: ReturnType<typeof createApp>[] = [];
10
+ const containers: HTMLElement[] = [];
11
+
12
+ afterEach(async () => {
13
+ for (const app of mounts.splice(0)) app.unmount();
14
+ for (const el of containers.splice(0)) el.remove();
15
+ vi.unstubAllGlobals();
16
+ vi.useRealTimers();
17
+ });
18
+
19
+ const here = join(dirname(fileURLToPath(import.meta.url)));
20
+
21
+ /** Freeze rAF entirely: Vue's <Transition> engine double-raf's the
22
+ * leave-from → leave-to class flip before it even arms its
23
+ * transitionend wait, so a frozen rAF reproduces the occluded-webview
24
+ * pathology exactly — the leave can NEVER complete on its own and only
25
+ * the component's watchdog can finalize it. */
26
+ function freezeRaf(): void {
27
+ vi.stubGlobal("requestAnimationFrame", (_cb: FrameRequestCallback) => 0 as unknown as number);
28
+ vi.stubGlobal("cancelAnimationFrame", () => {});
29
+ }
30
+
31
+ /** Mount an open modal wired to an `open` ref we can flip from the test. */
32
+ async function mountOpenModal() {
33
+ const container = document.createElement("div");
34
+ document.body.appendChild(container);
35
+ containers.push(container);
36
+
37
+ const open = ref(true);
38
+ const afterLeaveEvents: number[] = [];
39
+ const Wrapper = defineComponent({
40
+ setup() {
41
+ return () =>
42
+ h(HkModal, {
43
+ modelValue: open.value,
44
+ closable: true,
45
+ "onUpdate:modelValue": (v: boolean) => { open.value = v; },
46
+ onAfterLeave: () => { afterLeaveEvents.push(1); },
47
+ }, { default: () => h("div", "content") });
48
+ },
49
+ });
50
+ const app = createApp(Wrapper);
51
+ mounts.push(app);
52
+ app.mount(container);
53
+ await nextTick();
54
+ return { open, afterLeaveEvents };
55
+ }
56
+
57
+ describe("HkModal leave-completion watchdog", () => {
58
+ // Regression for the field-reported frozen modal (2026-09): with rAF
59
+ // starved, the Transition leave can never complete on its own and the
60
+ // panel used to stay over the page forever — undismissable, only a
61
+ // full reload escaped it. The watchdog must finalize within its
62
+ // budget, unmount both surface layers, and emit afterLeave exactly
63
+ // the way a real transition would.
64
+ it("force-finalizes a stalled leave within the watchdog budget", async () => {
65
+ vi.useFakeTimers();
66
+ freezeRaf();
67
+ const { open, afterLeaveEvents } = await mountOpenModal();
68
+ expect(document.querySelector(".hk-modal-content")).not.toBeNull();
69
+
70
+ open.value = false;
71
+ await nextTick();
72
+ // Just inside the budget nothing else can have completed the leave.
73
+ await vi.advanceTimersByTimeAsync(590);
74
+ await nextTick();
75
+ expect(document.querySelector(".hk-modal-content")).not.toBeNull();
76
+
77
+ await vi.advanceTimersByTimeAsync(100);
78
+ await nextTick();
79
+ expect(document.querySelector(".hk-modal-content")).toBeNull();
80
+ expect(document.querySelector(".hk-modal-overlay")).toBeNull();
81
+ expect(afterLeaveEvents).toHaveLength(1);
82
+ });
83
+
84
+ // The finalize flag must reset on reopen: without the reset a second
85
+ // close after a forced first finalize would be a no-op and the modal
86
+ // would freeze open forever instead.
87
+ // Vue resolves an OPEN-INTERRUPTED leave without a cancelled flag: a
88
+ // reopen patching over a still-live leave fires the old leave's
89
+ // onAfterLeave. The finalize must bail out while the surface is open
90
+ // again, or the stale completion kills the freshly reopened modal
91
+ // (unregisters its handle, unmounts the panel, emits a spurious
92
+ // afterLeave).
93
+ it("ignores a stale leave completion firing during reopen", async () => {
94
+ vi.useFakeTimers();
95
+ freezeRaf();
96
+ const { open, afterLeaveEvents } = await mountOpenModal();
97
+
98
+ open.value = false;
99
+ await nextTick();
100
+ await vi.advanceTimersByTimeAsync(50); // leave live, nothing finalized
101
+ open.value = true; // reopen patches over the live leave
102
+ await nextTick();
103
+ await vi.advanceTimersByTimeAsync(700); // past the watchdog budget
104
+ await nextTick();
105
+ // The reopened modal must survive both the stale onAfterLeave and
106
+ // the (disarmed) watchdog from the aborted close.
107
+ expect(document.querySelector(".hk-modal-content")).not.toBeNull();
108
+ expect(afterLeaveEvents).toHaveLength(0);
109
+
110
+ // And the surface still closes normally afterwards.
111
+ open.value = false;
112
+ await nextTick();
113
+ await vi.advanceTimersByTimeAsync(700);
114
+ await nextTick();
115
+ expect(document.querySelector(".hk-modal-content")).toBeNull();
116
+ expect(afterLeaveEvents).toHaveLength(1);
117
+ });
118
+
119
+ it("re-arms across open/close cycles after a forced finalize", async () => {
120
+ vi.useFakeTimers();
121
+ freezeRaf();
122
+ const { open, afterLeaveEvents } = await mountOpenModal();
123
+
124
+ open.value = false;
125
+ await nextTick();
126
+ await vi.advanceTimersByTimeAsync(700);
127
+ await nextTick();
128
+ expect(document.querySelector(".hk-modal-content")).toBeNull();
129
+ expect(afterLeaveEvents).toHaveLength(1);
130
+
131
+ open.value = true;
132
+ await nextTick();
133
+ expect(document.querySelector(".hk-modal-content")).not.toBeNull();
134
+
135
+ open.value = false;
136
+ await nextTick();
137
+ await vi.advanceTimersByTimeAsync(700);
138
+ await nextTick();
139
+ expect(document.querySelector(".hk-modal-content")).toBeNull();
140
+ expect(afterLeaveEvents).toHaveLength(2);
141
+ });
142
+
143
+ // On a healthy surface (real rAF, no transitionend needed — happy-dom
144
+ // reports no CSS transitions so Vue completes instantly) the real
145
+ // path owns finalization and the watchdog is a silent no-op: exactly
146
+ // one afterLeave per close, no duplicates from the pending timer.
147
+ it("leaves normal completion to the Transition engine without double-emitting", async () => {
148
+ vi.useFakeTimers();
149
+ const { open, afterLeaveEvents } = await mountOpenModal();
150
+
151
+ open.value = false;
152
+ await nextTick();
153
+ await vi.advanceTimersByTimeAsync(700);
154
+ await nextTick();
155
+ expect(document.querySelector(".hk-modal-content")).toBeNull();
156
+ expect(afterLeaveEvents).toHaveLength(1);
157
+
158
+ open.value = true;
159
+ await nextTick();
160
+ open.value = false;
161
+ await nextTick();
162
+ await vi.advanceTimersByTimeAsync(700);
163
+ await nextTick();
164
+ expect(afterLeaveEvents).toHaveLength(2);
165
+ });
166
+
167
+ // Contract pin: the watchdog must stay armed on the close path and
168
+ // its budget must stay bigger than any themed CSS leave
169
+ // (--hk-modal-duration defaults to 0.25s; a theme may raise it). A
170
+ // refactor that drops the arming — or shrinks the budget under the
171
+ // CSS timing — silently re-opens the frozen-modal failure mode.
172
+ it("pins the watchdog wiring and budget in the source", () => {
173
+ const src = readFileSync(join(here, "HkModal.tsx"), "utf-8");
174
+ expect(src).toContain("const LEAVE_WATCHDOG_MS = 600;");
175
+ expect(src).toContain("if (shouldRender.value) armLeaveWatchdog();");
176
+ expect(src).toContain("disarmLeaveWatchdog();");
177
+ expect(src).toContain("onAfterLeaveFinalize();");
178
+ });
179
+ });
@@ -194,6 +194,41 @@ export default defineComponent({
194
194
  let previouslyFocused: HTMLElement | null = null;
195
195
  let unmounted = false;
196
196
 
197
+ // ── Leave-completion watchdog ─────────────────────────────────────
198
+ // The close path hands unmounting to <Transition>'s leave, whose
199
+ // engine is rAF-driven: it double-raf's the leave-from → leave-to
200
+ // class flip and only THEN arms its transitionend wait. In an
201
+ // occluded/backgrounded webview rAF can starve for the whole leave
202
+ // window, the classes freeze in leave-from/leave-active, and the
203
+ // modal stays over the page forever — undismissable, only a full
204
+ // reload escaped it (field-reported 2026-09). The watchdog bounds
205
+ // the wait: if the leave has not finalized within a budget larger
206
+ // than any themed CSS leave (--hk-modal-duration defaults to 0.25s,
207
+ // themes may raise it), onAfterLeaveFinalize runs the exact same
208
+ // finalization the Transition would have. Normal closes disarm it;
209
+ // late or stale completions (post-watchdog real onAfterLeave, or
210
+ // the open-interrupted leave Vue resolves without a cancelled flag)
211
+ // are no-ops through the finalize and modelValue guards.
212
+ const LEAVE_WATCHDOG_MS = 600;
213
+ let leaveWatchdog: ReturnType<typeof setTimeout> | null = null;
214
+ let leaveFinalized = false;
215
+
216
+ function armLeaveWatchdog(): void {
217
+ disarmLeaveWatchdog();
218
+ leaveWatchdog = setTimeout(() => {
219
+ leaveWatchdog = null;
220
+ if (unmounted || props.modelValue || !shouldRender.value) return;
221
+ onAfterLeaveFinalize();
222
+ }, LEAVE_WATCHDOG_MS);
223
+ }
224
+
225
+ function disarmLeaveWatchdog(): void {
226
+ if (leaveWatchdog !== null) {
227
+ clearTimeout(leaveWatchdog);
228
+ leaveWatchdog = null;
229
+ }
230
+ }
231
+
197
232
  const overlayZ = computed(() => handle.value?.zIndex ?? 0);
198
233
  const contentZ = computed(() => (handle.value?.zIndex ?? 0) + 1);
199
234
  const resolvedWidth = computed(() => resolveModalWidth(props.width));
@@ -275,7 +310,15 @@ export default defineComponent({
275
310
  }
276
311
  }
277
312
 
278
- function onAfterLeave() {
313
+ function onAfterLeaveFinalize() {
314
+ // Finalizing while the surface is OPEN means this is a stale
315
+ // completion: Vue fires the interrupted leave's onAfterLeave (no
316
+ // cancelled flag) when a reopen patches over a still-live leave,
317
+ // and the watchdog callback separately guards on props.modelValue.
318
+ // Finalizing either of those would tear down the reopened modal.
319
+ if (unmounted || props.modelValue || leaveFinalized) return;
320
+ leaveFinalized = true;
321
+ disarmLeaveWatchdog();
279
322
  if (handle.value) {
280
323
  manager.unregister(handle.value.id);
281
324
  handle.value = null;
@@ -580,6 +623,8 @@ export default defineComponent({
580
623
  if (handle.value) {
581
624
  manager.unregister(handle.value.id);
582
625
  }
626
+ leaveFinalized = false;
627
+ disarmLeaveWatchdog();
583
628
  shouldRender.value = true;
584
629
  // Windows always block, so the kind alone lists this layer in
585
630
  // the modal-stack breadcrumb on every form factor.
@@ -594,6 +639,12 @@ export default defineComponent({
594
639
  // (e.g. immediate), clean up now.
595
640
  overlay.close();
596
641
  backGuard.release();
642
+ // Bound the Transition leave: if rAF starvation froze the
643
+ // class flip, the watchdog finalizes in the watchdog's place
644
+ // (see LEAVE_WATCHDOG_MS above). Only an actually-mounted
645
+ // surface can stall — a never-opened modal has nothing to
646
+ // finalize.
647
+ if (shouldRender.value) armLeaveWatchdog();
597
648
  }
598
649
  },
599
650
  { immediate: true },
@@ -626,6 +677,7 @@ export default defineComponent({
626
677
 
627
678
  onBeforeUnmount(() => {
628
679
  unmounted = true;
680
+ disarmLeaveWatchdog();
629
681
  detachBodyScrollbar();
630
682
  teardownWindowed();
631
683
  teardownAutoFollow();
@@ -709,7 +761,7 @@ export default defineComponent({
709
761
  }}
710
762
  onAfterLeave={() => {
711
763
  contentHooks.onAfterLeave();
712
- onAfterLeave();
764
+ onAfterLeaveFinalize();
713
765
  }}
714
766
  >
715
767
  {props.modelValue && (