@celestia-island/hikari 0.40.12 → 0.40.13
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/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,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
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { computed, defineComponent } from "vue";
|
|
2
|
+
|
|
3
|
+
import { useI18n } from "../i18n/context";
|
|
4
|
+
import HButton from "../components/HkButton";
|
|
5
|
+
import { HkErrorLanding } from "../components/HkErrorLanding";
|
|
6
|
+
import { HkJsonTree } from "../components/HkJsonTree";
|
|
7
|
+
import {
|
|
8
|
+
getErrorReportingOptions,
|
|
9
|
+
useErrorReportingState,
|
|
10
|
+
} from "./state";
|
|
11
|
+
|
|
12
|
+
import "./HkErrorReportingOverlay.scss";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* HkErrorReportingOverlay — the full-viewport takeover for uncaught errors.
|
|
16
|
+
*
|
|
17
|
+
* Renders nothing until the error-reporting state is raised. The card is
|
|
18
|
+
* the family-wide HkErrorLanding (same design language as every unified
|
|
19
|
+
* error surface): tone icon, headline, the error name as the code chip,
|
|
20
|
+
* the message as the description, the raw error record in a collapsible
|
|
21
|
+
* JSON tree, and Home / Retry actions.
|
|
22
|
+
*
|
|
23
|
+
* Mounted by `createErrorReporting` on a dedicated root appended to
|
|
24
|
+
* `document.body` via its own tiny app instance, so it keeps working even
|
|
25
|
+
* when the host component tree is the thing that crashed.
|
|
26
|
+
*/
|
|
27
|
+
export const HkErrorReportingOverlay = defineComponent({
|
|
28
|
+
name: "HkErrorReportingOverlay",
|
|
29
|
+
setup() {
|
|
30
|
+
const { t } = useI18n();
|
|
31
|
+
const state = useErrorReportingState();
|
|
32
|
+
|
|
33
|
+
const detailsValue = computed<Record<string, unknown> | undefined>(() => {
|
|
34
|
+
const err = state.value;
|
|
35
|
+
if (!err) return undefined;
|
|
36
|
+
const record: Record<string, unknown> = {
|
|
37
|
+
name: err.name,
|
|
38
|
+
message: err.message,
|
|
39
|
+
source: err.source,
|
|
40
|
+
};
|
|
41
|
+
if (err.stack) record.stack = err.stack;
|
|
42
|
+
if (err.info) record.info = err.info;
|
|
43
|
+
return record;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
function goHome() {
|
|
47
|
+
const options = getErrorReportingOptions();
|
|
48
|
+
if (options.onHome) {
|
|
49
|
+
options.onHome();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const href = options.homeHref === undefined ? "/" : options.homeHref;
|
|
53
|
+
if (href !== false) window.location.assign(href);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function retry() {
|
|
57
|
+
const options = getErrorReportingOptions();
|
|
58
|
+
if (options.onRetry) {
|
|
59
|
+
options.onRetry();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
window.location.reload();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return () => {
|
|
66
|
+
const err = state.value;
|
|
67
|
+
if (!err) return null;
|
|
68
|
+
const options = getErrorReportingOptions();
|
|
69
|
+
|
|
70
|
+
const description = options.describe
|
|
71
|
+
? options.describe(err)
|
|
72
|
+
: err.message || t("hikari::errors.unexpectedDesc", "An unhandled error occurred.");
|
|
73
|
+
|
|
74
|
+
const showHome = Boolean(options.onHome) || options.homeHref !== false;
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<div class="hk-error-reporting-overlay" role="alertdialog" aria-live="assertive" aria-modal="true">
|
|
78
|
+
<HkErrorLanding
|
|
79
|
+
title={options.title || t("hikari::errors.defaultTitle", "Something went wrong")}
|
|
80
|
+
description={description}
|
|
81
|
+
code={err.name}
|
|
82
|
+
>
|
|
83
|
+
{{
|
|
84
|
+
default: () => <HkJsonTree value={detailsValue.value} ariaLabel="stack trace" />,
|
|
85
|
+
actions: () => [
|
|
86
|
+
...(showHome
|
|
87
|
+
? [(
|
|
88
|
+
<HButton key="home" size="sm" onClick={goHome}>
|
|
89
|
+
{t("hikari::errors.backHome", "Back to home")}
|
|
90
|
+
</HButton>
|
|
91
|
+
)]
|
|
92
|
+
: []),
|
|
93
|
+
<HButton key="retry" size="sm" variant="secondary" onClick={retry}>
|
|
94
|
+
{t("hikari::errorBoundary.retry", "Retry")}
|
|
95
|
+
</HButton>,
|
|
96
|
+
],
|
|
97
|
+
}}
|
|
98
|
+
</HkErrorLanding>
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
};
|
|
102
|
+
},
|
|
103
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createApp, defineComponent, h, nextTick } from "vue";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
clearGlobalError,
|
|
6
|
+
createErrorReporting,
|
|
7
|
+
reportGlobalError,
|
|
8
|
+
resetErrorReportingForTests,
|
|
9
|
+
type HkErrorReportingOptions,
|
|
10
|
+
} from "./index";
|
|
11
|
+
|
|
12
|
+
const Boom = defineComponent({
|
|
13
|
+
name: "Boom",
|
|
14
|
+
setup() {
|
|
15
|
+
throw new TypeError("Cannot read properties of undefined (reading 'length')");
|
|
16
|
+
},
|
|
17
|
+
render: () => h("div", "never"),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
function installPlugin(options?: HkErrorReportingOptions) {
|
|
21
|
+
// A throwaway app: the plugin's window hooks and module state outlive it,
|
|
22
|
+
// exactly like the real host app installing it at bootstrap.
|
|
23
|
+
const container = document.createElement("div");
|
|
24
|
+
document.body.appendChild(container);
|
|
25
|
+
const app = createApp({ render: () => null });
|
|
26
|
+
app.use(createErrorReporting(options));
|
|
27
|
+
app.mount(container);
|
|
28
|
+
app.unmount();
|
|
29
|
+
container.remove();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function mountThrowingApp() {
|
|
33
|
+
const container = document.createElement("div");
|
|
34
|
+
document.body.appendChild(container);
|
|
35
|
+
const app = createApp(Boom);
|
|
36
|
+
app.use(createErrorReporting());
|
|
37
|
+
app.mount(container);
|
|
38
|
+
return { app, container };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function overlayHost(): HTMLElement | null {
|
|
42
|
+
return document.querySelector<HTMLElement>("[data-hikari-error-reporting]");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
resetErrorReportingForTests();
|
|
47
|
+
document.querySelectorAll("[data-hikari-error-reporting]").forEach((el) => el.remove());
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("createErrorReporting", () => {
|
|
51
|
+
it("shows the unified error landing for uncaught render errors", async () => {
|
|
52
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
53
|
+
const { app, container } = mountThrowingApp();
|
|
54
|
+
await nextTick();
|
|
55
|
+
|
|
56
|
+
const host = overlayHost();
|
|
57
|
+
expect(host).not.toBeNull();
|
|
58
|
+
expect(host!.querySelector(".hk-error-landing")).not.toBeNull();
|
|
59
|
+
expect(host!.querySelector(".hk-error-landing__code")!.textContent).toBe("TypeError");
|
|
60
|
+
expect(host!.querySelector(".hk-error-landing__desc")!.textContent)
|
|
61
|
+
.toBe("Cannot read properties of undefined (reading 'length')");
|
|
62
|
+
// Raw details ride in the JSON tree.
|
|
63
|
+
expect(host!.querySelector(".s-tool-json-tree")).not.toBeNull();
|
|
64
|
+
|
|
65
|
+
app.unmount();
|
|
66
|
+
container.remove();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("renders Home and Retry actions and hides Home when disabled", async () => {
|
|
70
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
71
|
+
installPlugin();
|
|
72
|
+
reportGlobalError(new Error("boom"));
|
|
73
|
+
await nextTick();
|
|
74
|
+
|
|
75
|
+
const host = overlayHost()!;
|
|
76
|
+
const labels = Array.from(host.querySelectorAll("button")).map((b) => b.textContent);
|
|
77
|
+
expect(labels).toContain("Back to home");
|
|
78
|
+
expect(labels).toContain("Retry");
|
|
79
|
+
|
|
80
|
+
clearGlobalError();
|
|
81
|
+
|
|
82
|
+
installPlugin({ homeHref: false });
|
|
83
|
+
reportGlobalError(new Error("boom"));
|
|
84
|
+
await nextTick();
|
|
85
|
+
const labels2 = Array.from(overlayHost()!.querySelectorAll("button")).map((b) => b.textContent);
|
|
86
|
+
expect(labels2).not.toContain("Back to home");
|
|
87
|
+
expect(labels2).toContain("Retry");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("takes over for unhandled window errors and rejections", async () => {
|
|
91
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
92
|
+
installPlugin();
|
|
93
|
+
|
|
94
|
+
window.dispatchEvent(new ErrorEvent("error", { error: new RangeError("out of range") }));
|
|
95
|
+
await nextTick();
|
|
96
|
+
expect(overlayHost()!.querySelector(".hk-error-landing__code")!.textContent).toBe("RangeError");
|
|
97
|
+
|
|
98
|
+
clearGlobalError();
|
|
99
|
+
|
|
100
|
+
// happy-dom has no PromiseRejectionEvent constructor; a plain event with
|
|
101
|
+
// a `reason` property exercises the same listener path.
|
|
102
|
+
const rejection = new Event("unhandledrejection");
|
|
103
|
+
Object.defineProperty(rejection, "reason", { value: new Error("async boom") });
|
|
104
|
+
window.dispatchEvent(rejection);
|
|
105
|
+
await nextTick();
|
|
106
|
+
expect(overlayHost()!.querySelector(".hk-error-landing__desc")!.textContent).toBe("async boom");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("keeps the first error and still fires onError for later ones", () => {
|
|
110
|
+
const onError = vi.fn();
|
|
111
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
112
|
+
installPlugin({ onError });
|
|
113
|
+
|
|
114
|
+
reportGlobalError(new Error("first"));
|
|
115
|
+
reportGlobalError(new Error("second"));
|
|
116
|
+
expect(overlayHost()!.querySelector(".hk-error-landing__desc")!.textContent).toBe("first");
|
|
117
|
+
expect(onError).toHaveBeenCalledTimes(2);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("drops errors rejected by shouldReport", () => {
|
|
121
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
122
|
+
installPlugin({ shouldReport: (_err, source) => source === "manual" });
|
|
123
|
+
|
|
124
|
+
window.dispatchEvent(new ErrorEvent("error", { error: new Error("filtered away") }));
|
|
125
|
+
expect(overlayHost()).toBeNull();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("chains a pre-existing app errorHandler", () => {
|
|
129
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
130
|
+
const previous = vi.fn();
|
|
131
|
+
const container = document.createElement("div");
|
|
132
|
+
document.body.appendChild(container);
|
|
133
|
+
const app = createApp({ render: () => null });
|
|
134
|
+
app.config.errorHandler = previous;
|
|
135
|
+
app.use(createErrorReporting());
|
|
136
|
+
app.mount(container);
|
|
137
|
+
app.config.errorHandler!(new Error("routed"), null, "setup function");
|
|
138
|
+
expect(previous).toHaveBeenCalledTimes(1);
|
|
139
|
+
expect(overlayHost()).not.toBeNull();
|
|
140
|
+
app.unmount();
|
|
141
|
+
container.remove();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("uses custom onRetry instead of reloading", async () => {
|
|
145
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
146
|
+
const onRetry = vi.fn();
|
|
147
|
+
installPlugin({ onRetry });
|
|
148
|
+
reportGlobalError(new Error("boom"));
|
|
149
|
+
await nextTick();
|
|
150
|
+
|
|
151
|
+
const retry = Array.from(overlayHost()!.querySelectorAll<HTMLButtonElement>("button"))
|
|
152
|
+
.find((b) => b.textContent === "Retry")!;
|
|
153
|
+
retry.click();
|
|
154
|
+
expect(onRetry).toHaveBeenCalledTimes(1);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("clearGlobalError tears the overlay down", async () => {
|
|
158
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
159
|
+
reportGlobalError(new Error("boom"));
|
|
160
|
+
await nextTick();
|
|
161
|
+
expect(overlayHost()).not.toBeNull();
|
|
162
|
+
clearGlobalError();
|
|
163
|
+
expect(overlayHost()).toBeNull();
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { createApp, type App, type Plugin } from "vue";
|
|
2
|
+
|
|
3
|
+
import { HkErrorReportingOverlay } from "./HkErrorReportingOverlay";
|
|
4
|
+
import {
|
|
5
|
+
clearErrorReportingState,
|
|
6
|
+
reportError,
|
|
7
|
+
setErrorReportingOptions,
|
|
8
|
+
type HkErrorReportingOptions,
|
|
9
|
+
type HkErrorSource,
|
|
10
|
+
} from "./state";
|
|
11
|
+
|
|
12
|
+
export type {
|
|
13
|
+
HkErrorReportingOptions,
|
|
14
|
+
HkErrorSource,
|
|
15
|
+
HkReportedError,
|
|
16
|
+
} from "./state";
|
|
17
|
+
export { HkErrorReportingOverlay } from "./HkErrorReportingOverlay";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* createErrorReporting — the install-and-forget global error reporting hook.
|
|
21
|
+
*
|
|
22
|
+
* `app.use(createErrorReporting())` is all a host needs to get the
|
|
23
|
+
* family-wide unified error landing for every error nothing else caught:
|
|
24
|
+
*
|
|
25
|
+
* - `app.config.errorHandler` (render trees without an HErrorBoundary,
|
|
26
|
+
* watchers, lifecycle hooks, async component failures, …);
|
|
27
|
+
* - uncaught `window` error events;
|
|
28
|
+
* - `unhandledrejection` events.
|
|
29
|
+
*
|
|
30
|
+
* On the first accepted error the plugin mounts a dedicated overlay app on
|
|
31
|
+
* `document.body` (z-index above every popup band) rendering HkErrorLanding
|
|
32
|
+
* with Home / Retry actions. Because the overlay lives on its own tiny app
|
|
33
|
+
* instance, it still shows when the host tree itself is what crashed.
|
|
34
|
+
*
|
|
35
|
+
* Any pre-existing `app.config.errorHandler` is chained, not discarded.
|
|
36
|
+
* All capture channels are optional; `shouldReport` filters, `onError` is
|
|
37
|
+
* the telemetry hook.
|
|
38
|
+
*/
|
|
39
|
+
export function createErrorReporting(options: HkErrorReportingOptions = {}): Plugin {
|
|
40
|
+
return {
|
|
41
|
+
install(app: App) {
|
|
42
|
+
setErrorReportingOptions(options);
|
|
43
|
+
|
|
44
|
+
const previous = app.config.errorHandler;
|
|
45
|
+
app.config.errorHandler = (err, instance, info) => {
|
|
46
|
+
if (previous) {
|
|
47
|
+
try {
|
|
48
|
+
previous(err, instance, info);
|
|
49
|
+
} catch {
|
|
50
|
+
// A broken custom handler must not swallow the report.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (reportError(err, "vue", info)) ensureOverlayMounted();
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
installWindowHooks(options);
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Raise the global error landing programmatically — the runtime-error
|
|
63
|
+
* counterpart of the old `window.__appFatal` handoff: fatal-fallback
|
|
64
|
+
* surfaces can route post-mount reports here once the SPA is up.
|
|
65
|
+
*/
|
|
66
|
+
export function reportGlobalError(err: unknown, source: HkErrorSource = "manual"): void {
|
|
67
|
+
if (reportError(err, source)) ensureOverlayMounted();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Dismiss the landing and tear the overlay app down (programmatic reset). */
|
|
71
|
+
export function clearGlobalError(): void {
|
|
72
|
+
clearErrorReportingState();
|
|
73
|
+
unmountOverlay();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── window hooks ────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
let windowCleanup: (() => void) | null = null;
|
|
79
|
+
|
|
80
|
+
function installWindowHooks(options: HkErrorReportingOptions): void {
|
|
81
|
+
if (typeof window === "undefined") return;
|
|
82
|
+
if (windowCleanup) return; // One set of listeners per document, ever.
|
|
83
|
+
|
|
84
|
+
const disposers: Array<() => void> = [];
|
|
85
|
+
|
|
86
|
+
if (options.captureWindow !== false) {
|
|
87
|
+
const onError = (event: ErrorEvent) => {
|
|
88
|
+
// Resource-load failures never reach here (no capture phase); script
|
|
89
|
+
// errors always carry `error` or at least a `message`.
|
|
90
|
+
const err = event.error ?? new Error(event.message || "Unknown script error");
|
|
91
|
+
if (reportError(err, "window")) ensureOverlayMounted();
|
|
92
|
+
};
|
|
93
|
+
window.addEventListener("error", onError);
|
|
94
|
+
disposers.push(() => window.removeEventListener("error", onError));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (options.captureRejection !== false) {
|
|
98
|
+
const onRejection = (event: PromiseRejectionEvent) => {
|
|
99
|
+
if (reportError(event.reason, "rejection")) ensureOverlayMounted();
|
|
100
|
+
};
|
|
101
|
+
window.addEventListener("unhandledrejection", onRejection);
|
|
102
|
+
disposers.push(() => window.removeEventListener("unhandledrejection", onRejection));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (disposers.length === 0) return;
|
|
106
|
+
windowCleanup = () => {
|
|
107
|
+
for (const dispose of disposers) dispose();
|
|
108
|
+
windowCleanup = null;
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── dedicated overlay app ───────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
let overlayApp: App | null = null;
|
|
115
|
+
let overlayHost: HTMLElement | null = null;
|
|
116
|
+
let overlayMounting = false;
|
|
117
|
+
|
|
118
|
+
function ensureOverlayMounted(): void {
|
|
119
|
+
if (overlayApp || overlayMounting) return;
|
|
120
|
+
if (typeof document === "undefined") return;
|
|
121
|
+
overlayMounting = true;
|
|
122
|
+
try {
|
|
123
|
+
overlayHost = document.createElement("div");
|
|
124
|
+
overlayHost.dataset.hikariErrorReporting = "";
|
|
125
|
+
document.body.appendChild(overlayHost);
|
|
126
|
+
overlayApp = createApp(HkErrorReportingOverlay);
|
|
127
|
+
overlayApp.mount(overlayHost);
|
|
128
|
+
} catch (err) {
|
|
129
|
+
// Never let overlay mounting recurse into another report.
|
|
130
|
+
console.error("[hikari:error-reporting] failed to mount the overlay", err);
|
|
131
|
+
overlayHost?.remove();
|
|
132
|
+
overlayHost = null;
|
|
133
|
+
overlayApp = null;
|
|
134
|
+
} finally {
|
|
135
|
+
overlayMounting = false;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function unmountOverlay(): void {
|
|
140
|
+
if (overlayApp) {
|
|
141
|
+
try {
|
|
142
|
+
overlayApp.unmount();
|
|
143
|
+
} catch {
|
|
144
|
+
// Already gone.
|
|
145
|
+
}
|
|
146
|
+
overlayApp = null;
|
|
147
|
+
}
|
|
148
|
+
overlayHost?.remove();
|
|
149
|
+
overlayHost = null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Test-only: reset singleton state, listeners and the overlay app. */
|
|
153
|
+
export function resetErrorReportingForTests(): void {
|
|
154
|
+
clearErrorReportingState();
|
|
155
|
+
windowCleanup?.();
|
|
156
|
+
windowCleanup = null;
|
|
157
|
+
unmountOverlay();
|
|
158
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { ref, type Ref } from "vue";
|
|
2
|
+
|
|
3
|
+
/** Where a reported error came from. */
|
|
4
|
+
export type HkErrorSource = "vue" | "window" | "rejection" | "manual";
|
|
5
|
+
|
|
6
|
+
/** A normalized error record carried by the global error landing. */
|
|
7
|
+
export interface HkReportedError {
|
|
8
|
+
source: HkErrorSource;
|
|
9
|
+
error: unknown;
|
|
10
|
+
name: string;
|
|
11
|
+
message: string;
|
|
12
|
+
stack: string;
|
|
13
|
+
/** Vue's `info` string for errors surfaced through `app.config.errorHandler`. */
|
|
14
|
+
info?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface HkErrorReportingOptions {
|
|
18
|
+
/** Headline override; defaults to `hikari::errors.defaultTitle`. */
|
|
19
|
+
title?: string;
|
|
20
|
+
/** Description builder; defaults to the error's message, falling back to
|
|
21
|
+
* `hikari::errors.unexpectedDesc` when the error carries none. */
|
|
22
|
+
describe?: (err: HkReportedError) => string;
|
|
23
|
+
/** Target of the "back to home" action. `false` hides the button.
|
|
24
|
+
* Ignored when `onHome` is set. Default `/`. */
|
|
25
|
+
homeHref?: string | false;
|
|
26
|
+
/** Overrides the default `window.location.assign(homeHref)` home action. */
|
|
27
|
+
onHome?: () => void;
|
|
28
|
+
/** Overrides the default `window.location.reload()` retry action. */
|
|
29
|
+
onRetry?: () => void;
|
|
30
|
+
/** Listen for uncaught `window` error events. Default `true`. */
|
|
31
|
+
captureWindow?: boolean;
|
|
32
|
+
/** Listen for `unhandledrejection` events. Default `true`. */
|
|
33
|
+
captureRejection?: boolean;
|
|
34
|
+
/** Drop an error entirely (no overlay, no `onError` callback). */
|
|
35
|
+
shouldReport?: (err: unknown, source: HkErrorSource) => boolean;
|
|
36
|
+
/** Telemetry hook: logging / reporting pipeline. Fires for every accepted
|
|
37
|
+
* error, including ones arriving while the landing is already up. */
|
|
38
|
+
onError?: (err: unknown, source: HkErrorSource) => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function normalizeError(err: unknown): { name: string; message: string; stack: string } {
|
|
42
|
+
if (err instanceof Error) {
|
|
43
|
+
return {
|
|
44
|
+
name: err.name || "Error",
|
|
45
|
+
message: err.message || String(err),
|
|
46
|
+
stack: err.stack || "",
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
return { name: "Error", message: String(err), stack: "" };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Module-level singleton state for the global error reporting overlay.
|
|
54
|
+
* Module scope (not app provide/inject) is deliberate: window hooks and the
|
|
55
|
+
* dedicated overlay app must reach it without a host component tree — the
|
|
56
|
+
* host tree is exactly what is broken when this fires.
|
|
57
|
+
*/
|
|
58
|
+
const currentError: Ref<HkReportedError | null> = ref(null);
|
|
59
|
+
|
|
60
|
+
/** Options of the most recent `createErrorReporting` install. */
|
|
61
|
+
let activeOptions: HkErrorReportingOptions = {};
|
|
62
|
+
|
|
63
|
+
export function getErrorReportingOptions(): HkErrorReportingOptions {
|
|
64
|
+
return activeOptions;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function setErrorReportingOptions(options: HkErrorReportingOptions): void {
|
|
68
|
+
activeOptions = options;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function useErrorReportingState(): Ref<HkReportedError | null> {
|
|
72
|
+
return currentError;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Raise the global error landing. The first error wins while the landing is
|
|
77
|
+
* up; later errors still log and fire `onError` but never swap the card.
|
|
78
|
+
* Returns the record when accepted, `null` when filtered or already showing.
|
|
79
|
+
*/
|
|
80
|
+
export function reportError(err: unknown, source: HkErrorSource, info?: string): HkReportedError | null {
|
|
81
|
+
const options = activeOptions;
|
|
82
|
+
if (options.shouldReport && !options.shouldReport(err, source)) return null;
|
|
83
|
+
|
|
84
|
+
console.error(`[hikari:error-reporting:${source}]`, err);
|
|
85
|
+
options.onError?.(err, source);
|
|
86
|
+
|
|
87
|
+
if (currentError.value !== null) return null;
|
|
88
|
+
|
|
89
|
+
const normalized = normalizeError(err);
|
|
90
|
+
const record: HkReportedError = {
|
|
91
|
+
source,
|
|
92
|
+
error: err,
|
|
93
|
+
name: normalized.name,
|
|
94
|
+
message: normalized.message,
|
|
95
|
+
stack: normalized.stack,
|
|
96
|
+
info,
|
|
97
|
+
};
|
|
98
|
+
currentError.value = record;
|
|
99
|
+
return record;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function clearErrorReportingState(): void {
|
|
103
|
+
currentError.value = null;
|
|
104
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"errors": {
|
|
3
3
|
"hikari::errors.defaultTitle": "حدث خطأ ما",
|
|
4
|
-
"hikari::errors.rawDetails": "تفاصيل الخطأ الأصلية"
|
|
4
|
+
"hikari::errors.rawDetails": "تفاصيل الخطأ الأصلية",
|
|
5
|
+
"hikari::errors.backHome": "العودة إلى الرئيسية",
|
|
6
|
+
"hikari::errors.unexpectedDesc": "واجه التطبيق خطأً غير معالج. يمكنك العودة إلى الصفحة الرئيسية أو إعادة المحاولة."
|
|
5
7
|
}
|
|
6
8
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"errors": {
|
|
3
3
|
"hikari::errors.defaultTitle": "Etwas ist schiefgelaufen",
|
|
4
|
-
"hikari::errors.rawDetails": "Fehlerdetails im Original"
|
|
4
|
+
"hikari::errors.rawDetails": "Fehlerdetails im Original",
|
|
5
|
+
"hikari::errors.backHome": "Zur Startseite",
|
|
6
|
+
"hikari::errors.unexpectedDesc": "In der Anwendung ist ein unbehandelter Fehler aufgetreten. Sie können zur Startseite zurückkehren oder es erneut versuchen."
|
|
5
7
|
}
|
|
6
8
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"errors": {
|
|
3
3
|
"hikari::errors.defaultTitle": "Something went wrong",
|
|
4
|
-
"hikari::errors.rawDetails": "Raw error details"
|
|
4
|
+
"hikari::errors.rawDetails": "Raw error details",
|
|
5
|
+
"hikari::errors.backHome": "Back to home",
|
|
6
|
+
"hikari::errors.unexpectedDesc": "The application hit an unhandled error. You can go back to the home page or retry."
|
|
5
7
|
}
|
|
6
8
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"errors": {
|
|
3
3
|
"hikari::errors.defaultTitle": "Algo salió mal",
|
|
4
|
-
"hikari::errors.rawDetails": "Detalles del error original"
|
|
4
|
+
"hikari::errors.rawDetails": "Detalles del error original",
|
|
5
|
+
"hikari::errors.backHome": "Volver al inicio",
|
|
6
|
+
"hikari::errors.unexpectedDesc": "La aplicación encontró un error no controlado. Puede volver al inicio o reintentar."
|
|
5
7
|
}
|
|
6
8
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"errors": {
|
|
3
3
|
"hikari::errors.defaultTitle": "Une erreur est survenue",
|
|
4
|
-
"hikari::errors.rawDetails": "Détails bruts de l'erreur"
|
|
4
|
+
"hikari::errors.rawDetails": "Détails bruts de l'erreur",
|
|
5
|
+
"hikari::errors.backHome": "Retour à l'accueil",
|
|
6
|
+
"hikari::errors.unexpectedDesc": "L'application a rencontré une erreur non gérée. Vous pouvez revenir à l'accueil ou réessayer."
|
|
5
7
|
}
|
|
6
8
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"errors": {
|
|
3
3
|
"hikari::errors.defaultTitle": "問題が発生しました",
|
|
4
|
-
"hikari::errors.rawDetails": "エラーの詳細(原文)"
|
|
4
|
+
"hikari::errors.rawDetails": "エラーの詳細(原文)",
|
|
5
|
+
"hikari::errors.backHome": "ホームに戻る",
|
|
6
|
+
"hikari::errors.unexpectedDesc": "アプリケーションで未処理のエラーが発生しました。ホームに戻るか、再試行できます。"
|
|
5
7
|
}
|
|
6
8
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"errors": {
|
|
3
3
|
"hikari::errors.defaultTitle": "문제가 발생했습니다",
|
|
4
|
-
"hikari::errors.rawDetails": "원본 오류 세부 정보"
|
|
4
|
+
"hikari::errors.rawDetails": "원본 오류 세부 정보",
|
|
5
|
+
"hikari::errors.backHome": "홈으로 돌아가기",
|
|
6
|
+
"hikari::errors.unexpectedDesc": "애플리케이션에서 처리되지 않은 오류가 발생했습니다. 홈으로 돌아가거나 다시 시도할 수 있습니다."
|
|
5
7
|
}
|
|
6
8
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"errors": {
|
|
3
3
|
"hikari::errors.defaultTitle": "Algo deu errado",
|
|
4
|
-
"hikari::errors.rawDetails": "Detalhes do erro original"
|
|
4
|
+
"hikari::errors.rawDetails": "Detalhes do erro original",
|
|
5
|
+
"hikari::errors.backHome": "Voltar ao início",
|
|
6
|
+
"hikari::errors.unexpectedDesc": "O aplicativo encontrou um erro não tratado. Você pode voltar ao início ou tentar novamente."
|
|
5
7
|
}
|
|
6
8
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"errors": {
|
|
3
3
|
"hikari::errors.defaultTitle": "Что-то пошло не так",
|
|
4
|
-
"hikari::errors.rawDetails": "Исходные детали ошибки"
|
|
4
|
+
"hikari::errors.rawDetails": "Исходные детали ошибки",
|
|
5
|
+
"hikari::errors.backHome": "На главную",
|
|
6
|
+
"hikari::errors.unexpectedDesc": "В приложении произошла необработанная ошибка. Вы можете вернуться на главную страницу или повторить попытку."
|
|
5
7
|
}
|
|
6
8
|
}
|
package/src/index.ts
CHANGED
|
@@ -111,7 +111,17 @@ export { default as HErrorBoundary } from "./components/HkErrorBoundary";
|
|
|
111
111
|
export {
|
|
112
112
|
HkErrorLanding as HErrorLanding,
|
|
113
113
|
type HErrorTone,
|
|
114
|
+
type HErrorLandingVariant,
|
|
114
115
|
} from "./components/HkErrorLanding";
|
|
116
|
+
export { HkErrorReportingOverlay as HErrorReportingOverlay } from "./errorReporting";
|
|
117
|
+
export {
|
|
118
|
+
createErrorReporting,
|
|
119
|
+
reportGlobalError,
|
|
120
|
+
clearGlobalError,
|
|
121
|
+
type HkErrorReportingOptions,
|
|
122
|
+
type HkErrorSource,
|
|
123
|
+
type HkReportedError,
|
|
124
|
+
} from "./errorReporting";
|
|
115
125
|
export { default as HDraggableList } from "./components/HkDraggableList";
|
|
116
126
|
export { default as HDraggableGrid } from "./components/HkDraggableGrid";
|
|
117
127
|
export { default as HSelectionGrid } from "./components/HkSelectionGrid";
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
// Use theme variables with namespace to avoid conflicts
|
|
2
|
-
@use "./hikari-vars" as vars;
|
|
3
|
-
|
|
4
|
-
.hk-error-boundary {
|
|
5
|
-
display: flex;
|
|
6
|
-
align-items: center;
|
|
7
|
-
justify-content: center;
|
|
8
|
-
padding: 2rem;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
.hk-error-boundary-card {
|
|
12
|
-
max-width: 28rem;
|
|
13
|
-
width: 100%;
|
|
14
|
-
background: rgb(from var(--hi-color-error, #f85149) r g b / 0.06);
|
|
15
|
-
border: 1px solid rgb(from var(--hi-color-error, #f85149) r g b / 0.18);
|
|
16
|
-
border-radius: 8px;
|
|
17
|
-
padding: 1.25rem;
|
|
18
|
-
display: flex;
|
|
19
|
-
flex-direction: column;
|
|
20
|
-
gap: 0.75rem;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
.hk-error-boundary-header {
|
|
24
|
-
display: flex;
|
|
25
|
-
align-items: center;
|
|
26
|
-
gap: 0.5rem;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
.hk-error-boundary-icon {
|
|
30
|
-
color: var(--hi-color-error, #f85149);
|
|
31
|
-
flex-shrink: 0;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
.hk-error-boundary-label {
|
|
35
|
-
font-size: 0.75rem;
|
|
36
|
-
font-weight: 600;
|
|
37
|
-
color: var(--hi-color-error, #f85149);
|
|
38
|
-
letter-spacing: 0.02em;
|
|
39
|
-
text-transform: uppercase;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
.hk-error-boundary-tag {
|
|
43
|
-
font-size: 0.75rem;
|
|
44
|
-
font-weight: 500;
|
|
45
|
-
padding: 0.0625rem 0.375rem;
|
|
46
|
-
border-radius: 4px;
|
|
47
|
-
background: rgb(from var(--hi-color-error, #f85149) r g b / 0.12);
|
|
48
|
-
color: var(--hi-color-error, #f85149);
|
|
49
|
-
font-family: var(--font-mono, vars.$hikari-font-family-mono);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
.hk-error-boundary-msg {
|
|
53
|
-
font-size: 0.8125rem;
|
|
54
|
-
line-height: 1.5;
|
|
55
|
-
color: var(--hi-color-text-primary, #c9d1d9);
|
|
56
|
-
opacity: 0.85;
|
|
57
|
-
word-break: break-word;
|
|
58
|
-
white-space: pre-wrap;
|
|
59
|
-
font-family: var(--font-mono, vars.$hikari-font-family-mono);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
.hk-error-boundary-actions {
|
|
63
|
-
display: flex;
|
|
64
|
-
align-items: center;
|
|
65
|
-
justify-content: flex-end;
|
|
66
|
-
gap: 0.5rem;
|
|
67
|
-
}
|