@celestia-island/hikari 0.40.12 → 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 +1 -1
- package/src/components/HkErrorBoundary.test.tsx +103 -0
- package/src/components/HkErrorBoundary.tsx +65 -43
- package/src/components/HkErrorLanding.scss +9 -0
- package/src/components/HkErrorLanding.test.tsx +8 -0
- package/src/components/HkErrorLanding.tsx +12 -1
- package/src/components/HkModal.transition-completion.test.tsx +179 -0
- package/src/components/HkModal.tsx +54 -2
- package/src/errorReporting/HkErrorReportingOverlay.scss +9 -0
- package/src/errorReporting/HkErrorReportingOverlay.tsx +103 -0
- package/src/errorReporting/index.test.tsx +165 -0
- package/src/errorReporting/index.ts +158 -0
- package/src/errorReporting/state.ts +104 -0
- package/src/i18n/locales/ar/errors.json +3 -1
- package/src/i18n/locales/de/errors.json +3 -1
- package/src/i18n/locales/en/errors.json +3 -1
- package/src/i18n/locales/es/errors.json +3 -1
- package/src/i18n/locales/fr/errors.json +3 -1
- package/src/i18n/locales/ja/errors.json +3 -1
- package/src/i18n/locales/ko/errors.json +3 -1
- package/src/i18n/locales/pt/errors.json +3 -1
- package/src/i18n/locales/ru/errors.json +3 -1
- package/src/i18n/locales/zh-Hans/errors.json +3 -1
- package/src/i18n/locales/zh-Hant/errors.json +3 -1
- package/src/index.ts +10 -0
- package/src/components/HkErrorBoundary.scss +0 -67
package/package.json
CHANGED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h, nextTick, type Component } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkErrorBoundary from "./HkErrorBoundary";
|
|
5
|
+
|
|
6
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
7
|
+
|
|
8
|
+
function mountWith(child: Component, props: Record<string, unknown> = {}) {
|
|
9
|
+
const container = document.createElement("div");
|
|
10
|
+
document.body.appendChild(container);
|
|
11
|
+
const app = createApp({
|
|
12
|
+
render: () => h(HkErrorBoundary, props, { default: () => h(child) }),
|
|
13
|
+
});
|
|
14
|
+
app.config.errorHandler = () => {}; // Silence Vue's own duplicate logging.
|
|
15
|
+
app.mount(container);
|
|
16
|
+
mounts.push({ app, container });
|
|
17
|
+
return container;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const Boom = defineComponent({
|
|
21
|
+
name: "Boom",
|
|
22
|
+
setup() {
|
|
23
|
+
throw new TypeError("Cannot read properties of undefined (reading 'length')");
|
|
24
|
+
},
|
|
25
|
+
render: () => h("div", "never"),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const Fine = defineComponent({
|
|
29
|
+
name: "Fine",
|
|
30
|
+
render: () => h("div", { class: "fine-child" }, "fine"),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
for (const { app, container } of mounts) {
|
|
35
|
+
app.unmount();
|
|
36
|
+
container.remove();
|
|
37
|
+
}
|
|
38
|
+
mounts.length = 0;
|
|
39
|
+
vi.restoreAllMocks();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("HkErrorBoundary", () => {
|
|
43
|
+
it("renders children while nothing throws", () => {
|
|
44
|
+
const el = mountWith(Fine);
|
|
45
|
+
expect(el.querySelector(".fine-child")).not.toBeNull();
|
|
46
|
+
expect(el.querySelector(".hk-error-landing")).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("captures a crash and renders the inline error landing", async () => {
|
|
50
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
51
|
+
const el = mountWith(Boom);
|
|
52
|
+
await nextTick();
|
|
53
|
+
|
|
54
|
+
const landing = el.querySelector(".hk-error-landing");
|
|
55
|
+
expect(landing).not.toBeNull();
|
|
56
|
+
expect(landing!.classList.contains("is-inline")).toBe(true);
|
|
57
|
+
expect(el.querySelector(".hk-error-landing__code")!.textContent).toBe("TypeError");
|
|
58
|
+
expect(el.querySelector(".hk-error-landing__desc")!.textContent)
|
|
59
|
+
.toBe("Cannot read properties of undefined (reading 'length')");
|
|
60
|
+
// Raw details ride in the JSON tree.
|
|
61
|
+
expect(el.querySelector(".s-tool-json-tree")).not.toBeNull();
|
|
62
|
+
expect(errorSpy).toHaveBeenCalled();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("falls back to the default headline when no title is given", async () => {
|
|
66
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
67
|
+
const el = mountWith(Boom);
|
|
68
|
+
await nextTick();
|
|
69
|
+
expect(el.querySelector(".hk-error-landing__title")!.textContent).toBe("Something went wrong");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("honours a custom title override", async () => {
|
|
73
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
74
|
+
const el = mountWith(Boom, { errorTitle: "发生了错误" });
|
|
75
|
+
await nextTick();
|
|
76
|
+
expect(el.querySelector(".hk-error-landing__title")!.textContent).toBe("发生了错误");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("recovers via the retry action", async () => {
|
|
80
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
81
|
+
const el = mountWith(Boom, { retryLabel: "重试" });
|
|
82
|
+
await nextTick();
|
|
83
|
+
|
|
84
|
+
const buttons = Array.from(el.querySelectorAll<HTMLButtonElement>("button"));
|
|
85
|
+
const retry = buttons.find((b) => b.textContent === "重试");
|
|
86
|
+
expect(retry).toBeDefined();
|
|
87
|
+
retry!.click();
|
|
88
|
+
await nextTick();
|
|
89
|
+
// The slot rerender throws again, so the landing stays — but the error
|
|
90
|
+
// ref was cleared and recaptured (boundary proven by a fresh record).
|
|
91
|
+
expect(el.querySelector(".hk-error-landing")).not.toBeNull();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("keeps the custom fallback render prop contract", async () => {
|
|
95
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
96
|
+
const el = mountWith(Boom, {
|
|
97
|
+
fallback: (err: string, retry: () => void) => h("div", { class: "custom-fallback" }, [err.slice(0, 10), h("button", { onClick: retry }, "go")]),
|
|
98
|
+
});
|
|
99
|
+
await nextTick();
|
|
100
|
+
expect(el.querySelector(".custom-fallback")).not.toBeNull();
|
|
101
|
+
expect(el.querySelector(".hk-error-landing")).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -1,14 +1,38 @@
|
|
|
1
|
-
import { Copy, RefreshCw
|
|
2
|
-
import { defineComponent, onErrorCaptured, ref, type PropType, type VNode } from "vue";
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import { Copy, RefreshCw } from "lucide-vue-next";
|
|
2
|
+
import { computed, defineComponent, onErrorCaptured, ref, type PropType, type VNode } from "vue";
|
|
5
3
|
|
|
6
4
|
import { useClipboard } from "../runtime/useClipboard";
|
|
7
5
|
import { useI18n } from "../i18n/context";
|
|
8
6
|
import HButton from "./HkButton";
|
|
9
|
-
import
|
|
10
|
-
import "./
|
|
7
|
+
import { HkErrorLanding } from "./HkErrorLanding";
|
|
8
|
+
import { HkJsonTree } from "./HkJsonTree";
|
|
9
|
+
|
|
10
|
+
interface CapturedError {
|
|
11
|
+
name: string;
|
|
12
|
+
message: string;
|
|
13
|
+
stack: string;
|
|
14
|
+
}
|
|
11
15
|
|
|
16
|
+
function captureError(err: unknown): CapturedError {
|
|
17
|
+
if (err instanceof Error) {
|
|
18
|
+
return { name: err.name || "Error", message: err.message, stack: err.stack || "" };
|
|
19
|
+
}
|
|
20
|
+
return { name: "Error", message: String(err), stack: "" };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function formatError(err: CapturedError): string {
|
|
24
|
+
return err.stack ? `${err.name}: ${err.message}\n\n${err.stack}` : `${err.name}: ${err.message}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* HkErrorBoundary — inline crash guard rendering the shared error landing.
|
|
29
|
+
*
|
|
30
|
+
* Captures descendant errors via `onErrorCaptured` and stops propagation.
|
|
31
|
+
* The built-in fallback is the same HkErrorLanding card the family's
|
|
32
|
+
* full-page takeovers use (inline variant): tone icon, headline, the error
|
|
33
|
+
* name as the code chip, the message as the description, the raw
|
|
34
|
+
* name/message/stack in a collapsible JSON tree, plus retry / copy actions.
|
|
35
|
+
*/
|
|
12
36
|
export default defineComponent({
|
|
13
37
|
name: "HkErrorBoundary",
|
|
14
38
|
props: {
|
|
@@ -21,15 +45,12 @@ export default defineComponent({
|
|
|
21
45
|
setup(props, { slots }) {
|
|
22
46
|
const clipboard = useClipboard();
|
|
23
47
|
const { t } = useI18n();
|
|
24
|
-
const error = ref<
|
|
48
|
+
const error = ref<CapturedError | null>(null);
|
|
25
49
|
|
|
26
50
|
onErrorCaptured((err) => {
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
: String(err);
|
|
31
|
-
console.error(`[ErrorBoundary:${props.name}]`, msg);
|
|
32
|
-
error.value = msg;
|
|
51
|
+
const captured = captureError(err);
|
|
52
|
+
console.error(`[ErrorBoundary:${props.name}]`, err);
|
|
53
|
+
error.value = captured;
|
|
33
54
|
return false;
|
|
34
55
|
});
|
|
35
56
|
|
|
@@ -37,48 +58,49 @@ export default defineComponent({
|
|
|
37
58
|
error.value = null;
|
|
38
59
|
}
|
|
39
60
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
61
|
+
const detailsValue = computed(() => {
|
|
62
|
+
if (!error.value) return undefined;
|
|
63
|
+
const record: Record<string, unknown> = {
|
|
64
|
+
name: error.value.name,
|
|
65
|
+
message: error.value.message,
|
|
66
|
+
};
|
|
67
|
+
if (error.value.stack) record.stack = error.value.stack;
|
|
68
|
+
if (props.name !== "unknown") record.boundary = props.name;
|
|
69
|
+
return record;
|
|
70
|
+
});
|
|
43
71
|
|
|
44
72
|
return () => {
|
|
45
73
|
if (error.value === null) {
|
|
46
74
|
return slots.default?.();
|
|
47
75
|
}
|
|
48
76
|
|
|
77
|
+
const err = error.value;
|
|
78
|
+
|
|
49
79
|
if (props.fallback) {
|
|
50
|
-
return props.fallback(
|
|
80
|
+
return props.fallback(formatError(err), retry);
|
|
51
81
|
}
|
|
52
82
|
|
|
53
83
|
return (
|
|
54
|
-
<
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
<div style={{ maxHeight: "12rem" }}>
|
|
65
|
-
<HScrollContainer>
|
|
66
|
-
{error.value}
|
|
67
|
-
</HScrollContainer>
|
|
68
|
-
</div>
|
|
69
|
-
</div>
|
|
70
|
-
<div class="hk-error-boundary-actions">
|
|
71
|
-
<HButton variant="ghost" size="sm" onClick={copyError}>
|
|
72
|
-
<Copy size={12} />
|
|
73
|
-
{props.copyErrorLabel || t("hikari::errorBoundary.copyError", "Copy Error")}
|
|
74
|
-
</HButton>
|
|
75
|
-
<HButton variant="outline" size="sm" onClick={retry}>
|
|
84
|
+
<HkErrorLanding
|
|
85
|
+
variant="inline"
|
|
86
|
+
title={props.errorTitle || t("hikari::errors.defaultTitle", "Something went wrong")}
|
|
87
|
+
description={err.message}
|
|
88
|
+
code={err.name}
|
|
89
|
+
>
|
|
90
|
+
{{
|
|
91
|
+
default: () => <HkJsonTree value={detailsValue.value} ariaLabel="stack trace" />,
|
|
92
|
+
actions: () => [
|
|
93
|
+
<HButton variant="primary" size="sm" onClick={retry}>
|
|
76
94
|
<RefreshCw size={12} />
|
|
77
95
|
{props.retryLabel || t("hikari::errorBoundary.retry", "Retry")}
|
|
78
|
-
</HButton
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
96
|
+
</HButton>,
|
|
97
|
+
<HButton variant="ghost" size="sm" onClick={() => clipboard.copy(formatError(err))}>
|
|
98
|
+
<Copy size={12} />
|
|
99
|
+
{props.copyErrorLabel || t("hikari::errorBoundary.copyError", "Copy Error")}
|
|
100
|
+
</HButton>,
|
|
101
|
+
],
|
|
102
|
+
}}
|
|
103
|
+
</HkErrorLanding>
|
|
82
104
|
);
|
|
83
105
|
};
|
|
84
106
|
},
|
|
@@ -181,6 +181,15 @@
|
|
|
181
181
|
margin-top: var(--space-16, 1rem);
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
+
// Inline variant: the same card without the viewport takeover — used when
|
|
185
|
+
// HkErrorBoundary captures a crash inside a pane and swaps in the landing
|
|
186
|
+
// in-place instead of covering the whole screen.
|
|
187
|
+
.hk-error-landing.is-inline {
|
|
188
|
+
min-height: 0;
|
|
189
|
+
padding: var(--space-20, 1.25rem) var(--space-12, 0.75rem);
|
|
190
|
+
background: transparent;
|
|
191
|
+
}
|
|
192
|
+
|
|
184
193
|
@media (max-width: 480px) {
|
|
185
194
|
.hk-error-landing__card {
|
|
186
195
|
padding: 1.5rem 1.125rem;
|
|
@@ -11,6 +11,7 @@ interface MountOptions {
|
|
|
11
11
|
code?: string;
|
|
12
12
|
status?: number;
|
|
13
13
|
tone?: "error" | "warning" | "info";
|
|
14
|
+
variant?: "page" | "inline";
|
|
14
15
|
detailsOpen?: boolean;
|
|
15
16
|
details?: () => ReturnType<typeof h>;
|
|
16
17
|
actions?: () => ReturnType<typeof h>;
|
|
@@ -28,6 +29,7 @@ function mountLanding(opts: MountOptions = {}) {
|
|
|
28
29
|
code: opts.code ?? "",
|
|
29
30
|
status: opts.status,
|
|
30
31
|
tone: opts.tone ?? "error",
|
|
32
|
+
variant: opts.variant ?? "page",
|
|
31
33
|
detailsOpen: opts.detailsOpen ?? true,
|
|
32
34
|
}, {
|
|
33
35
|
...(opts.details ? { default: opts.details } : {}),
|
|
@@ -99,4 +101,10 @@ describe("HkErrorLanding", () => {
|
|
|
99
101
|
expect(el.querySelector(".fake-action")).not.toBeNull();
|
|
100
102
|
expect(el.querySelector(".fake-brand")).not.toBeNull();
|
|
101
103
|
});
|
|
104
|
+
|
|
105
|
+
it("applies the inline variant class without the page backdrop", () => {
|
|
106
|
+
const el = mountLanding({ variant: "inline", title: "Boom" });
|
|
107
|
+
const root = el.querySelector(".hk-error-landing")!;
|
|
108
|
+
expect(root.classList.contains("is-inline")).toBe(true);
|
|
109
|
+
});
|
|
102
110
|
});
|
|
@@ -7,6 +7,15 @@ import "./HkErrorLanding.scss";
|
|
|
7
7
|
/** Visual severity of the landing icon and accents. */
|
|
8
8
|
export type HErrorTone = "error" | "warning" | "info";
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Layout variant of the landing.
|
|
12
|
+
* - `page` (default): owns a full-viewport backdrop — for overlays and
|
|
13
|
+
* standalone error pages.
|
|
14
|
+
* - `inline`: drops the backdrop and viewport height so the same card can
|
|
15
|
+
* live inside a pane captured by HkErrorBoundary.
|
|
16
|
+
*/
|
|
17
|
+
export type HErrorLandingVariant = "page" | "inline";
|
|
18
|
+
|
|
10
19
|
/**
|
|
11
20
|
* HkErrorLanding — the shared full-page error landing.
|
|
12
21
|
*
|
|
@@ -33,6 +42,8 @@ export const HkErrorLanding = defineComponent({
|
|
|
33
42
|
/** HTTP status chip, e.g. 400. */
|
|
34
43
|
status: { type: Number, default: undefined },
|
|
35
44
|
tone: { type: String as PropType<HErrorTone>, default: "error" },
|
|
45
|
+
/** Layout variant: `page` (viewport backdrop) or `inline` (in-flow card). */
|
|
46
|
+
variant: { type: String as PropType<HErrorLandingVariant>, default: "page" },
|
|
36
47
|
/** Initial expansion of the raw-details section. */
|
|
37
48
|
detailsOpen: { type: Boolean, default: true },
|
|
38
49
|
},
|
|
@@ -48,7 +59,7 @@ export const HkErrorLanding = defineComponent({
|
|
|
48
59
|
}
|
|
49
60
|
|
|
50
61
|
return () => (
|
|
51
|
-
<div class={`hk-error-landing is-${props.tone}`}>
|
|
62
|
+
<div class={`hk-error-landing is-${props.tone}${props.variant === "inline" ? " is-inline" : ""}`}>
|
|
52
63
|
<div class="hk-error-landing__card">
|
|
53
64
|
{slots.brand?.()}
|
|
54
65
|
|
|
@@ -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
|
|
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
|
-
|
|
764
|
+
onAfterLeaveFinalize();
|
|
713
765
|
}}
|
|
714
766
|
>
|
|
715
767
|
{props.modelValue && (
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// HkErrorReportingOverlay — fixed full-viewport host for the error landing.
|
|
2
|
+
// Sits above every popup band (modal 1000 / dropdown 2000 / tooltip 3000 /
|
|
3
|
+
// toast 4000): an uncaught-error takeover must never be covered.
|
|
4
|
+
|
|
5
|
+
.hk-error-reporting-overlay {
|
|
6
|
+
position: fixed;
|
|
7
|
+
inset: 0;
|
|
8
|
+
z-index: 5000;
|
|
9
|
+
}
|