@eifi1/ui-kit 0.12.0 → 0.13.1

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.
Files changed (197) hide show
  1. package/README.md +10 -10
  2. package/dist/components/amount-input.d.ts +23 -1
  3. package/dist/components/amount-input.js +78 -13
  4. package/dist/components/amount-input.js.map +1 -1
  5. package/dist/components/bulk-action-bar.d.ts +10 -2
  6. package/dist/components/bulk-action-bar.js.map +1 -1
  7. package/dist/components/button-group.d.ts +1 -1
  8. package/dist/components/calculator.d.ts +1 -1
  9. package/dist/components/chart.js +14 -8
  10. package/dist/components/chart.js.map +1 -1
  11. package/dist/components/chip.js +20 -15
  12. package/dist/components/chip.js.map +1 -1
  13. package/dist/components/combobox-core.d.ts +3 -1
  14. package/dist/components/combobox-core.js +16 -5
  15. package/dist/components/combobox-core.js.map +1 -1
  16. package/dist/components/combobox.js.map +1 -1
  17. package/dist/components/copy-button.d.ts +1 -1
  18. package/dist/components/data-table-filter-popover.js +8 -1
  19. package/dist/components/data-table-filter-popover.js.map +1 -1
  20. package/dist/components/data-table.js +28 -20
  21. package/dist/components/data-table.js.map +1 -1
  22. package/dist/components/entity-combobox.js +22 -17
  23. package/dist/components/entity-combobox.js.map +1 -1
  24. package/dist/components/error-boundary.d.ts +202 -13
  25. package/dist/components/error-boundary.js +225 -21
  26. package/dist/components/error-boundary.js.map +1 -1
  27. package/dist/components/field-sync.js +90 -83
  28. package/dist/components/field-sync.js.map +1 -1
  29. package/dist/components/file-button.d.ts +1 -1
  30. package/dist/components/file-dropzone.d.ts +1 -1
  31. package/dist/components/form-actions.d.ts +1 -1
  32. package/dist/components/full-bleed-dialog.js +4 -0
  33. package/dist/components/full-bleed-dialog.js.map +1 -1
  34. package/dist/components/hover-menu.js +64 -56
  35. package/dist/components/hover-menu.js.map +1 -1
  36. package/dist/components/list.d.ts +14 -2
  37. package/dist/components/list.js +3 -1
  38. package/dist/components/list.js.map +1 -1
  39. package/dist/components/modal.js.map +1 -1
  40. package/dist/components/multi-entity-combobox.js +23 -17
  41. package/dist/components/multi-entity-combobox.js.map +1 -1
  42. package/dist/components/number-field.d.ts +1 -1
  43. package/dist/components/number-input.d.ts +1 -1
  44. package/dist/components/number-input.js.map +1 -1
  45. package/dist/components/numpad-sheet.d.ts +10 -2
  46. package/dist/components/numpad-sheet.js +6 -4
  47. package/dist/components/numpad-sheet.js.map +1 -1
  48. package/dist/components/page-header.d.ts +35 -6
  49. package/dist/components/page-header.js +7 -1
  50. package/dist/components/page-header.js.map +1 -1
  51. package/dist/components/picker-sheet.js +3 -0
  52. package/dist/components/picker-sheet.js.map +1 -1
  53. package/dist/components/popover.js +3 -0
  54. package/dist/components/popover.js.map +1 -1
  55. package/dist/components/settings-fields.d.ts +1 -1
  56. package/dist/components/signed-amount.d.ts +2 -1
  57. package/dist/components/signed-amount.js +1 -1
  58. package/dist/components/signed-amount.js.map +1 -1
  59. package/dist/components/stat-tile.js +1 -1
  60. package/dist/components/stat-tile.js.map +1 -1
  61. package/dist/components/swipeable-row.js +4 -3
  62. package/dist/components/swipeable-row.js.map +1 -1
  63. package/dist/components/text-link.d.ts +73 -11
  64. package/dist/components/text-link.js +45 -10
  65. package/dist/components/text-link.js.map +1 -1
  66. package/dist/components/text.d.ts +26 -2
  67. package/dist/components/text.js +16 -2
  68. package/dist/components/text.js.map +1 -1
  69. package/dist/components/time-input.d.ts +1 -1
  70. package/dist/components/toggle-group.d.ts +22 -0
  71. package/dist/components/toggle-group.js +8 -8
  72. package/dist/components/toggle-group.js.map +1 -1
  73. package/dist/components/toggle-legend.d.ts +17 -0
  74. package/dist/components/toggle-legend.js +6 -2
  75. package/dist/components/toggle-legend.js.map +1 -1
  76. package/dist/components/tooltip.d.ts +51 -1
  77. package/dist/components/tooltip.js +59 -35
  78. package/dist/components/tooltip.js.map +1 -1
  79. package/dist/components/ui.d.ts +1 -1
  80. package/dist/components/ui.js +166 -28
  81. package/dist/components/ui.js.map +1 -1
  82. package/dist/feedback/feedback-attachment.js +4 -6
  83. package/dist/feedback/feedback-attachment.js.map +1 -1
  84. package/dist/feedback/feedback-dialog.js +5 -3
  85. package/dist/feedback/feedback-dialog.js.map +1 -1
  86. package/dist/feedback/feedback-inbox.js +86 -75
  87. package/dist/feedback/feedback-inbox.js.map +1 -1
  88. package/dist/feedback/feedback-thread.d.ts +43 -3
  89. package/dist/feedback/feedback-thread.js +53 -26
  90. package/dist/feedback/feedback-thread.js.map +1 -1
  91. package/dist/feedback.d.ts +1 -1
  92. package/dist/hooks/use-file-drop.d.ts +1 -1
  93. package/dist/i18n/defaults.d.ts +1 -1
  94. package/dist/i18n/kit-labels.d.ts +1 -1
  95. package/dist/i18n/kit-labels.js.map +1 -1
  96. package/dist/i18n/locales/de-CH-informal.d.ts +1 -1
  97. package/dist/i18n/locales/de-CH.d.ts +1 -1
  98. package/dist/i18n/locales/de-informal.d.ts +1 -1
  99. package/dist/i18n/locales/de.d.ts +1 -1
  100. package/dist/i18n/locales/de.js +9 -1
  101. package/dist/i18n/locales/de.js.map +1 -1
  102. package/dist/i18n/locales/es.d.ts +1 -1
  103. package/dist/i18n/locales/es.js +9 -1
  104. package/dist/i18n/locales/es.js.map +1 -1
  105. package/dist/i18n/locales/fr.d.ts +1 -1
  106. package/dist/i18n/locales/fr.js +9 -1
  107. package/dist/i18n/locales/fr.js.map +1 -1
  108. package/dist/i18n/locales/hu.d.ts +1 -1
  109. package/dist/i18n/locales/hu.js +9 -1
  110. package/dist/i18n/locales/hu.js.map +1 -1
  111. package/dist/i18n/locales/it.d.ts +1 -1
  112. package/dist/i18n/locales/it.js +9 -1
  113. package/dist/i18n/locales/it.js.map +1 -1
  114. package/dist/i18n/locales/zh.d.ts +1 -1
  115. package/dist/i18n/locales/zh.js +9 -1
  116. package/dist/i18n/locales/zh.js.map +1 -1
  117. package/dist/index.d.ts +6 -6
  118. package/dist/{kit-labels-v3biUF1L.d.ts → kit-labels-Mz8zZHIF.d.ts} +102 -3
  119. package/dist/lib/format.d.ts +23 -5
  120. package/dist/lib/format.js +4 -2
  121. package/dist/lib/format.js.map +1 -1
  122. package/dist/rhf/fields.d.ts +59 -15
  123. package/dist/rhf/fields.js +41 -11
  124. package/dist/rhf/fields.js.map +1 -1
  125. package/dist/rhf/form.d.ts +1 -1
  126. package/dist/rhf.d.ts +2 -2
  127. package/dist/rhf.js.map +1 -1
  128. package/dist/search/command-palette.js +20 -14
  129. package/dist/search/command-palette.js.map +1 -1
  130. package/dist/shell/app-shell.d.ts +1 -1
  131. package/dist/shell/app-shell.js +62 -54
  132. package/dist/shell/app-shell.js.map +1 -1
  133. package/dist/shell/top-bar-brand.d.ts +1 -1
  134. package/dist/shell/topbar-action-menu.d.ts +9 -3
  135. package/dist/shell/topbar-action-menu.js +2 -1
  136. package/dist/shell/topbar-action-menu.js.map +1 -1
  137. package/dist/shell.d.ts +1 -1
  138. package/dist/tour/tour.js +10 -4
  139. package/dist/tour/tour.js.map +1 -1
  140. package/dist/wizard/stepper-nav.d.ts +1 -1
  141. package/dist/wizard/use-wizard.js +4 -2
  142. package/dist/wizard/use-wizard.js.map +1 -1
  143. package/dist/wizard/wizard-step.d.ts +11 -1
  144. package/dist/wizard/wizard-step.js +24 -6
  145. package/dist/wizard/wizard-step.js.map +1 -1
  146. package/dist/wizard.d.ts +1 -1
  147. package/package.json +6 -6
  148. package/src/components/amount-input.tsx +165 -12
  149. package/src/components/bulk-action-bar.tsx +16 -3
  150. package/src/components/chart.tsx +19 -8
  151. package/src/components/chip.tsx +6 -0
  152. package/src/components/combobox-core.tsx +28 -9
  153. package/src/components/combobox.tsx +2 -0
  154. package/src/components/data-table-filter-popover.tsx +13 -5
  155. package/src/components/data-table.tsx +6 -0
  156. package/src/components/entity-combobox.tsx +7 -4
  157. package/src/components/error-boundary.tsx +470 -30
  158. package/src/components/field-sync.tsx +48 -40
  159. package/src/components/full-bleed-dialog.tsx +4 -0
  160. package/src/components/hover-menu.tsx +26 -17
  161. package/src/components/list.tsx +17 -3
  162. package/src/components/modal.tsx +4 -0
  163. package/src/components/multi-entity-combobox.tsx +8 -4
  164. package/src/components/number-input.tsx +1 -0
  165. package/src/components/numpad-sheet.tsx +17 -3
  166. package/src/components/page-header.tsx +42 -5
  167. package/src/components/picker-sheet.tsx +3 -0
  168. package/src/components/popover.tsx +3 -0
  169. package/src/components/signed-amount.tsx +12 -3
  170. package/src/components/stat-tile.tsx +3 -1
  171. package/src/components/swipeable-row.tsx +7 -7
  172. package/src/components/text-link.tsx +126 -18
  173. package/src/components/text.tsx +47 -2
  174. package/src/components/toggle-group.tsx +35 -10
  175. package/src/components/toggle-legend.tsx +22 -1
  176. package/src/components/tooltip.tsx +79 -8
  177. package/src/components/ui.tsx +307 -39
  178. package/src/feedback/feedback-attachment.tsx +10 -7
  179. package/src/feedback/feedback-dialog.tsx +7 -4
  180. package/src/feedback/feedback-inbox.tsx +7 -0
  181. package/src/feedback/feedback-thread.tsx +109 -33
  182. package/src/i18n/kit-labels.tsx +16 -0
  183. package/src/i18n/locales/de.ts +8 -0
  184. package/src/i18n/locales/es.ts +8 -0
  185. package/src/i18n/locales/fr.ts +8 -0
  186. package/src/i18n/locales/hu.ts +8 -0
  187. package/src/i18n/locales/it.ts +8 -0
  188. package/src/i18n/locales/zh.ts +8 -0
  189. package/src/lib/format.ts +32 -7
  190. package/src/rhf/fields.tsx +99 -16
  191. package/src/rhf.ts +1 -1
  192. package/src/search/command-palette.tsx +29 -19
  193. package/src/shell/app-shell.tsx +6 -1
  194. package/src/shell/topbar-action-menu.tsx +11 -4
  195. package/src/tour/tour.tsx +16 -5
  196. package/src/wizard/use-wizard.ts +8 -4
  197. package/src/wizard/wizard-step.tsx +38 -6
@@ -1,8 +1,9 @@
1
1
  import { Component } from "react";
2
2
  import type { ErrorInfo, ReactNode } from "react";
3
- import { AlertTriangle } from "lucide-react";
3
+ import { AlertTriangle, RefreshCw, WifiOff } from "lucide-react";
4
4
  import { useKitLabels } from "../i18n/kit-labels";
5
5
  import { Button, EmptyState } from "./ui";
6
+ import { CopyButton } from "./copy-button";
6
7
  import { Disclosure } from "./disclosure";
7
8
 
8
9
  /** The words of the default fallback. */
@@ -13,8 +14,24 @@ export interface ErrorBoundaryLabels {
13
14
  message: string;
14
15
  /** The button that clears the error and renders the children again. */
15
16
  retry: string;
16
- /** The disclosure that opens the error's own text (`showDetails`). */
17
+ /** The disclosure that opens the full error report (`showDetails`). */
17
18
  details: string;
19
+ /** The button that reloads the whole page (0.13). */
20
+ reload: string;
21
+ /** Heading when a lazy chunk failed to load while online — a deploy replaced the
22
+ * files this page was built from (0.13). */
23
+ updateTitle: string;
24
+ updateMessage: string;
25
+ /** Heading when the browser says it is offline (0.13). */
26
+ offlineTitle: string;
27
+ offlineMessage: string;
28
+ /** The copy button's words: what it copies is the plain-text report (0.13). */
29
+ copyReport: string;
30
+ /** Shown once `onReport` has filed the crash and returned no reference (0.13). */
31
+ reported: string;
32
+ /** Shown once `onReport` has filed the crash under a reference — rendered as the
33
+ * app returned it, so return `"#123"` if the hash belongs to it (0.13). */
34
+ reportedAs: (reference: string) => string;
18
35
  }
19
36
 
20
37
  export const DEFAULT_ERROR_BOUNDARY_LABELS: ErrorBoundaryLabels = {
@@ -22,6 +39,14 @@ export const DEFAULT_ERROR_BOUNDARY_LABELS: ErrorBoundaryLabels = {
22
39
  message: "This part of the page could not be shown. Try again, or reload the page.",
23
40
  retry: "Try again",
24
41
  details: "Error details",
42
+ reload: "Reload",
43
+ updateTitle: "A new version is available",
44
+ updateMessage: "Part of the app has changed since this page was loaded. Reload to get the new version.",
45
+ offlineTitle: "You’re offline",
46
+ offlineMessage: "This page could not be loaded without a connection. Reconnect, then reload the page.",
47
+ copyReport: "Copy error report",
48
+ reported: "This error has been reported automatically.",
49
+ reportedAs: (reference) => `Reported as ${reference}`,
25
50
  };
26
51
 
27
52
  /**
@@ -48,19 +73,207 @@ function read(get: () => unknown): string | undefined {
48
73
  }
49
74
  }
50
75
 
76
+ /** `JSON.stringify` that never throws (a cycle, a BigInt, a hostile getter) and never
77
+ * returns more than 500 characters. "" when there is nothing to say. */
78
+ function safeJson(value: unknown): string {
79
+ try {
80
+ const json = JSON.stringify(value);
81
+ if (!json || json === "{}") return "";
82
+ return json.length > 500 ? `${json.slice(0, 499)}…` : json;
83
+ } catch {
84
+ return "";
85
+ }
86
+ }
87
+
51
88
  /** {@link ErrorBoundaryDetails} of any thrown value, without ever throwing. */
52
89
  export function describeThrown(error: unknown): ErrorBoundaryDetails {
53
90
  if (typeof error === "object" && error !== null) {
54
91
  const e = error as { name?: unknown; message?: unknown; stack?: unknown };
92
+ const message = read(() => e.message) ?? "";
55
93
  return {
56
94
  name: read(() => e.name) ?? "Error",
57
- message: read(() => e.message) ?? "",
95
+ // `throw { code: 500 }` has no message, and an empty line leaves crash triage with
96
+ // nothing (keksdose G3a; its old reporter filed `{"code":500}`). Such a value is
97
+ // quoted as JSON instead, capped, and never at the cost of a throw.
98
+ message: message || (error instanceof Error ? "" : safeJson(error)),
58
99
  stack: read(() => e.stack),
59
100
  };
60
101
  }
61
102
  return { name: "Error", message: read(() => error) ?? "" };
62
103
  }
63
104
 
105
+ /**
106
+ * Is this a failed lazy import — a route chunk that did not arrive?
107
+ *
108
+ * Two causes, and neither is a bug in the page: the device is offline and the chunk was
109
+ * never cached, or a deploy replaced the hashed files the running page still points at.
110
+ * Both are cured by a reload and by nothing else — `React.lazy` caches the rejected
111
+ * import, so Try again re-throws the same error without fetching. The patterns are
112
+ * keksdose's (crash-report.ts), which covers Vite ("dynamically imported module",
113
+ * "module script failed"), webpack ("ChunkLoadError", "Loading chunk") and the bare
114
+ * `fetch` failure a service worker hands back.
115
+ *
116
+ * Never throws: it runs inside the fallback's own render, where a throw is a white
117
+ * screen. An error it cannot read is not a chunk failure.
118
+ */
119
+ export function isChunkLoadError(
120
+ error: { name?: string | null; message?: string | null } | null | undefined,
121
+ ): boolean {
122
+ try {
123
+ const s = `${error?.name ?? ""} ${error?.message ?? ""}`;
124
+ return /ChunkLoadError|dynamically imported module|module script failed|Loading chunk|Failed to fetch/i.test(s);
125
+ } catch {
126
+ return false;
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Everything a crash report carries, read once when the boundary catches.
132
+ *
133
+ * Plain strings only, so it survives `JSON.stringify` into an app's crash endpoint as is
134
+ * and {@link formatCrashReport} can print it without a single guard. The wording of the
135
+ * printed report is English on purpose: it is read by whoever fixes the bug, not by the
136
+ * user who copies it, and a report in nine languages cannot be searched.
137
+ */
138
+ export interface CrashReport {
139
+ /** The thrown value's `name` ("TypeError"), "Error" for a non-object. */
140
+ name: string;
141
+ message: string;
142
+ stack?: string;
143
+ /** React's component stack from `componentDidCatch` — the part that names the
144
+ * component, which a minified JS stack no longer does. */
145
+ componentStack?: string;
146
+ /** `pathname + search` at the moment of the crash. The search may carry what the
147
+ * user typed — see `redact`. */
148
+ page: string;
149
+ /** ISO 8601, UTC. */
150
+ time: string;
151
+ userAgent: string;
152
+ /** The boundary's `appVersion` prop. */
153
+ appVersion?: string;
154
+ /** `navigator.onLine` at the moment of the crash; left out where there is none. */
155
+ online?: boolean;
156
+ /** {@link isChunkLoadError} of the thrown value. Such a crash is never handed to
157
+ * `onReport` — it is a deploy or a dropped connection, not a defect — but it is
158
+ * still in the copied report. */
159
+ chunkLoad: boolean;
160
+ /** The boundary's `reportExtras()`, stringified. */
161
+ extras: Record<string, string>;
162
+ }
163
+
164
+ /**
165
+ * The report as one plain-text block — what the copy button puts on the clipboard and
166
+ * the details disclosure shows.
167
+ *
168
+ * lenkbank's layout, because that is the one that was already being pasted into
169
+ * feedback messages: the error line first (it is what a search for the bug matches),
170
+ * then where / when / what, then the two stacks.
171
+ */
172
+ export function formatCrashReport(report: CrashReport): string {
173
+ const lines = [
174
+ report.message ? `${report.name}: ${report.message}` : report.name,
175
+ `Page: ${report.page}`,
176
+ `Time: ${report.time}`,
177
+ `User agent: ${report.userAgent}`,
178
+ ];
179
+ if (report.appVersion) lines.push(`App version: ${report.appVersion}`);
180
+ if (report.online === false) lines.push("Online: no");
181
+ for (const [key, value] of Object.entries(report.extras)) lines.push(`${key}: ${value}`);
182
+ if (report.stack) lines.push("", "Stack:", report.stack.trim());
183
+ if (report.componentStack) lines.push("", "Component stack:", report.componentStack.trim());
184
+ return lines.join("\n");
185
+ }
186
+
187
+ /**
188
+ * A key for "the same crash again": the error's name and message and the first frames
189
+ * of its stack.
190
+ *
191
+ * Page-load local, not a server-side grouping key (keksdose's crash-report.ts draws the
192
+ * same line): its only job is to stop one page load filing one bug twice — React's dev
193
+ * StrictMode, a Try again that crashes again, a render loop that trips the boundary many
194
+ * times a second. The page and the time are left out on purpose: the same bug on two
195
+ * routes is still one bug.
196
+ */
197
+ export function crashFingerprint(report: Pick<CrashReport, "name" | "message" | "stack">): string {
198
+ const frames = (report.stack ?? "").split("\n").slice(0, 4).join("|");
199
+ return `${report.name}::${report.message}::${frames}`;
200
+ }
201
+
202
+ /**
203
+ * What `onReport` may resolve with. `{ reference }` is shown ("Reported as …");
204
+ * nothing (or `{}`) means filed without one. `{ filed: false }` says the app decided NOT
205
+ * to file (a demo session, a hot-reload artefact, a report buffered for later): the
206
+ * screen then claims nothing and keeps the copy button — claiming a report that was
207
+ * never sent is the same false statement as hiding one that was.
208
+ */
209
+ export type CrashReportResult = { reference?: string; filed?: boolean } | void;
210
+
211
+ interface Filing {
212
+ filed: boolean;
213
+ reference?: string;
214
+ }
215
+
216
+ const NOT_FILED: Filing = { filed: false };
217
+
218
+ /** A render loop can trip a boundary dozens of times a second with a different message
219
+ * each time (a counter in it); without a ceiling the first bad deploy files hundreds of
220
+ * rows. keksdose's number. */
221
+ const MAX_REPORTS_PER_PAGE_LOAD = 5;
222
+
223
+ /** Fingerprint → the one filing for it in this page load. Module scope IS the page
224
+ * load: a reload starts it empty. A duplicate gets the first filing's promise, so the
225
+ * second screen shows the same reference instead of claiming nothing. */
226
+ const filings = new Map<string, Promise<Filing>>();
227
+
228
+ function settle(out: unknown): Filing {
229
+ try {
230
+ if (out && typeof out === "object") {
231
+ const o = out as { filed?: unknown; reference?: unknown };
232
+ if (o.filed === false) return NOT_FILED;
233
+ const reference = typeof o.reference === "string" && o.reference ? o.reference : undefined;
234
+ return { filed: true, reference };
235
+ }
236
+ return { filed: true };
237
+ } catch {
238
+ return NOT_FILED;
239
+ }
240
+ }
241
+
242
+ /** Hand the report to the app, once per fingerprint per page load, and never throw —
243
+ * not for a synchronous throw, a rejection, or a hostile thenable. */
244
+ function fileOnce(report: CrashReport, onReport: (report: CrashReport) => unknown): Promise<Filing> {
245
+ const key = crashFingerprint(report);
246
+ const known = filings.get(key);
247
+ if (known) return known;
248
+ if (filings.size >= MAX_REPORTS_PER_PAGE_LOAD) return Promise.resolve(NOT_FILED);
249
+ let filing: Promise<Filing>;
250
+ try {
251
+ filing = Promise.resolve(onReport(report)).then(settle, () => NOT_FILED);
252
+ } catch {
253
+ filing = Promise.resolve(NOT_FILED);
254
+ }
255
+ // Claimed before the first await: two catches in the same tick (StrictMode) must not
256
+ // both get past the lookup above.
257
+ filings.set(key, filing);
258
+ return filing;
259
+ }
260
+
261
+ function isOffline(): boolean {
262
+ try {
263
+ return typeof navigator !== "undefined" && navigator.onLine === false;
264
+ } catch {
265
+ return false;
266
+ }
267
+ }
268
+
269
+ function reloadPage(): void {
270
+ try {
271
+ window.location.reload();
272
+ } catch {
273
+ /* nothing left to do from here */
274
+ }
275
+ }
276
+
64
277
  /** What a render-prop `fallback` is handed. */
65
278
  export interface ErrorBoundaryFallbackProps {
66
279
  /** The thrown value, as it was thrown. Read it with care — see {@link describeThrown}. */
@@ -68,16 +281,21 @@ export interface ErrorBoundaryFallbackProps {
68
281
  details: ErrorBoundaryDetails;
69
282
  /** Clear the error and render the children again. */
70
283
  reset: () => void;
284
+ /** The crash report, once `componentDidCatch` has built it (null on the very first
285
+ * render of the fallback, which happens before it). Already `redact`ed. */
286
+ report: CrashReport | null;
71
287
  }
72
288
 
73
289
  export interface ErrorBoundaryProps {
74
290
  children?: ReactNode;
75
291
  /** Replaces the default fallback: a node, or a render function handed the error and
76
- * a `reset`. */
292
+ * a `reset`. The same rule applies to it as to the default: see the component docs. */
77
293
  fallback?: ReactNode | ((props: ErrorBoundaryFallbackProps) => ReactNode);
78
- /** Called once per caught error — log it, file a crash report. */
294
+ /** Called once per caught error with the raw value — log it. A throw from it is
295
+ * swallowed: the boundary is the last thing standing. For filing a report, prefer
296
+ * `onReport`, which gets the finished report, dedupe and the reference on screen. */
79
297
  onError?: (error: unknown, info: ErrorInfo) => void;
80
- /** Called when the error is cleared, by Retry, `reset` or a `resetKeys` change —
298
+ /** Called when the error is cleared, by Try again, `reset` or a `resetKeys` change —
81
299
  * drop a cache, refetch. */
82
300
  onReset?: () => void;
83
301
  /**
@@ -87,9 +305,59 @@ export interface ErrorBoundaryProps {
87
305
  * carry its fallback to the next one (keksdose's `resetKey`).
88
306
  */
89
307
  resetKeys?: ReadonlyArray<unknown>;
90
- /** A disclosure under the message with the error's name, message and stack — for an
91
- * internal tool, or while developing. Off by default: a stack means nothing to a user. */
308
+ /** Quote the error's `name: message` in a monospace box under the hint. Default on
309
+ * (0.13): three sentences true of every crash identify none of them, and the one
310
+ * line that does is what a user can read out or screenshot (lenkbank P7). Not shown
311
+ * for a chunk-load / offline screen, where the message is a URL that explains
312
+ * nothing the title does not. */
313
+ showMessage?: boolean;
314
+ /** A collapsed disclosure holding the full plain-text report. Default on since 0.13
315
+ * (it was an opt-in showing name, message and stack): closed, it costs one line, and
316
+ * it is where a support conversation asks the user to look. */
92
317
  showDetails?: boolean;
318
+ /** The copy button for the plain-text report (the kit's CopyButton, which says when
319
+ * the clipboard refused). Default on. Hidden once `onReport` has filed the crash —
320
+ * there is nothing left to paste anywhere — and shown again whenever it did not. */
321
+ copyReport?: boolean;
322
+ /** The app's version, printed in the report — the first question on every crash. */
323
+ appVersion?: string;
324
+ /** More `key: value` lines for the report, read when the boundary catches (a tenant
325
+ * id, the feature flags). A throw from it is swallowed and the lines are left out. */
326
+ reportExtras?: () => Record<string, string>;
327
+ /**
328
+ * Rewrite the report before anything sees it: the screen, the clipboard and
329
+ * `onReport` all get the returned one.
330
+ *
331
+ * THE REPORT IS STRUCTURE, NEVER CONTENT — and only the app knows which of its query
332
+ * params hold content. keksdose's register put the free-text search in `?q=` and payee
333
+ * names in the `f.*` filters, and its crash channel sent them in the clear to a column
334
+ * every admin reads (crash-report.ts `safeUrl`). The cure there is an ALLOW-list, so a
335
+ * param added later is dropped until somebody decides otherwise — rebuild `page`
336
+ * from `new URL(r.page, location.origin)`, keeping only the listed `searchParams`.
337
+ * `extras` are the app's own and need no second pass. A throw from `redact` falls
338
+ * back to the pathname alone and no extras — failing closed.
339
+ *
340
+ * The same holds inside `onReport`, which is the other place to do it: clamp fields
341
+ * to what the endpoint accepts, drop what it must not store. The kit cannot know.
342
+ */
343
+ redact?: (report: CrashReport) => CrashReport;
344
+ /**
345
+ * File the report automatically. Called from `componentDidCatch`, at most once per
346
+ * {@link crashFingerprint} per page load (and at most five times in all), never for a
347
+ * chunk-load error. It can NOT throw into the boundary: a synchronous throw and a
348
+ * rejection both read as "not filed", and the copy button stays.
349
+ *
350
+ * Resolve with `{ reference }` and the screen says "Reported as …" instead of the copy
351
+ * button; see {@link CrashReportResult} for "filed without a reference" and "decided not
352
+ * to file". Whatever the app's reporter does, it must do without the router, the query
353
+ * client or the auth store's hooks — a bare `fetch` with `keepalive`, so it survives
354
+ * the reload the user is about to press (keksdose crash-report.ts `post`).
355
+ */
356
+ onReport?: (report: CrashReport) => Promise<CrashReportResult> | CrashReportResult;
357
+ /** The app's own escape hatches, beside Reload / Try again — keksdose's "Clear local
358
+ * data" for a poisoned persisted cache that no in-app action could otherwise break.
359
+ * A full-width hint under it: wrap it in `<div className="basis-full">`. */
360
+ actions?: ReactNode;
93
361
  /** The default fallback's heading level. Default `h2`: the fallback often IS the page. */
94
362
  headingAs?: "h2" | "h3" | "h4" | "h5" | "h6";
95
363
  /** Classes for the default fallback's box (`m-8 min-h-[400px]`, kastlan). */
@@ -97,14 +365,26 @@ export interface ErrorBoundaryProps {
97
365
  labels?: Partial<ErrorBoundaryLabels>;
98
366
  }
99
367
 
368
+ type FilingState = "none" | "pending" | "filed" | "failed";
369
+
100
370
  interface ErrorBoundaryState {
101
371
  /** A flag of its own, so `throw undefined` is still an error shown. */
102
372
  failed: boolean;
103
373
  error: unknown;
104
374
  details: ErrorBoundaryDetails | null;
375
+ report: CrashReport | null;
376
+ filing: FilingState;
377
+ reference: string | undefined;
105
378
  }
106
379
 
107
- const CLEAR: ErrorBoundaryState = { failed: false, error: null, details: null };
380
+ const CLEAR: ErrorBoundaryState = {
381
+ failed: false,
382
+ error: null,
383
+ details: null,
384
+ report: null,
385
+ filing: "none",
386
+ reference: undefined,
387
+ };
108
388
 
109
389
  function keysChanged(a: ReadonlyArray<unknown> | undefined, b: ReadonlyArray<unknown> | undefined): boolean {
110
390
  if (a === b) return false;
@@ -112,14 +392,82 @@ function keysChanged(a: ReadonlyArray<unknown> | undefined, b: ReadonlyArray<unk
112
392
  return a.some((v, i) => !Object.is(v, b[i]));
113
393
  }
114
394
 
395
+ function currentPage(): { page: string; path: string } {
396
+ try {
397
+ const { pathname, search } = window.location;
398
+ return { page: `${pathname}${search}`, path: pathname };
399
+ } catch {
400
+ return { page: "", path: "" };
401
+ }
402
+ }
403
+
404
+ function buildReport(details: ErrorBoundaryDetails, componentStack: string | undefined, props: ErrorBoundaryProps): CrashReport {
405
+ const { page, path } = currentPage();
406
+ const extras: Record<string, string> = {};
407
+ try {
408
+ const more = props.reportExtras?.();
409
+ if (more) for (const [k, v] of Object.entries(more)) extras[k] = String(v);
410
+ } catch {
411
+ /* documented: the lines are left out */
412
+ }
413
+ let userAgent = "";
414
+ let online: boolean | undefined;
415
+ try {
416
+ userAgent = navigator.userAgent;
417
+ online = typeof navigator.onLine === "boolean" ? navigator.onLine : undefined;
418
+ } catch {
419
+ /* no navigator (SSR, a worker test) */
420
+ }
421
+ const report: CrashReport = {
422
+ name: details.name,
423
+ message: details.message,
424
+ stack: details.stack,
425
+ componentStack: componentStack ?? undefined,
426
+ page,
427
+ time: new Date().toISOString(),
428
+ userAgent,
429
+ appVersion: props.appVersion,
430
+ online,
431
+ chunkLoad: isChunkLoadError(details),
432
+ extras,
433
+ };
434
+ if (!props.redact) return report;
435
+ try {
436
+ return props.redact(report);
437
+ } catch {
438
+ // Fail closed: an app that asked for redaction must not get the unredacted page.
439
+ return { ...report, page: path, extras: {} };
440
+ }
441
+ }
442
+
115
443
  /**
116
- * Catch a render error below it and show a fallback instead of an empty page.
444
+ * Catch a render error below it and show ONE crash screen instead of an empty page.
117
445
  *
118
- * kastlan (shared/components/error-boundary.tsx) and keksdose (app/error-boundary.tsx)
119
- * each built one on `EmptyState tone="danger"`: a warning glyph, a title, a line of
120
- * explanation and a Retry. This is that fallback, translated through the provider
121
- * (`errorBoundary.*`), with the hooks an app hangs its own behaviour on — `onError`
122
- * for the crash report, `resetKeys` for the route change, `fallback` for anything else.
446
+ * The three apps each built one. kastlan's and keksdose's sat on `EmptyState
447
+ * tone="danger"`; lenkbank's quoted the error and offered a plain-text report to copy;
448
+ * keksdose's filed the crash itself, told a lazy-chunk failure apart as "offline", and
449
+ * offered "clear local data" when a poisoned cache made Retry useless (feedback #160).
450
+ * This is all of that, so the apps reduce to
451
+ * `<ErrorBoundary resetKeys={[pathname]} appVersion={__VERSION__} />` plus, for an app
452
+ * with a crash endpoint, `onReport` and `actions`:
453
+ *
454
+ * - the error's `name: message`, quoted (`showMessage`);
455
+ * - Try again (reset the boundary) AND Reload (the page) — Try again alone re-renders
456
+ * the same subtree from the same state, which a deterministic crash survives;
457
+ * - a chunk-load error or an offline browser gets its own title and Reload first
458
+ * ("A new version is available" / "You're offline");
459
+ * - the copy button and the report in a disclosure; `onReport` for filing it, deduped
460
+ * per page load, with its reference on screen.
461
+ *
462
+ * THE RULE for this fallback — and for a `fallback` or `actions` an app passes in: it
463
+ * must not depend on the router, the query client or the auth store. A crash is exactly
464
+ * when those may be what broke, and a fallback that throws escalates to the next
465
+ * boundary up — at the app level there is none, and the user gets the white screen this
466
+ * exists to prevent. So no `useNavigate`, no `useQuery`, no `useAuth()` in it; reload
467
+ * with `window.location`, not the router. The kit's own label hooks are the one context
468
+ * it does read (`errorBoundary.*` from `UiKitProvider`), and they degrade to the English
469
+ * defaults when there is no provider above — which is why the app-level boundary may
470
+ * sit OUTSIDE the provider and still render.
123
471
  *
124
472
  * The fallback is `role="alert"`: a page replaced by an error is news, and a reader
125
473
  * that was on it is told.
@@ -127,12 +475,45 @@ function keysChanged(a: ReadonlyArray<unknown> | undefined, b: ReadonlyArray<unk
127
475
  export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
128
476
  state: ErrorBoundaryState = CLEAR;
129
477
 
478
+ /** Bumped by every catch and reset, so a filing that settles after the screen moved
479
+ * on (Try again, a route change, a second crash) does not write into it. */
480
+ private generation = 0;
481
+
482
+ /**
483
+ * Forget which crashes this page load has filed, and the count towards the cap.
484
+ *
485
+ * For tests: the dedupe lives in module scope because a page load is what it spans,
486
+ * and a test file is one page load — so without this, the sixth test that files a
487
+ * report finds the cap reached and `onReport` never called. Call it in `beforeEach`.
488
+ * Never needed in an app: a reload is the reset.
489
+ */
490
+ static forgetReports(): void {
491
+ filings.clear();
492
+ }
493
+
130
494
  static getDerivedStateFromError(error: unknown): Partial<ErrorBoundaryState> {
131
- return { failed: true, error, details: describeThrown(error) };
495
+ return { failed: true, error, details: describeThrown(error), report: null, filing: "none", reference: undefined };
132
496
  }
133
497
 
134
498
  componentDidCatch(error: unknown, info: ErrorInfo): void {
135
- this.props.onError?.(error, info);
499
+ try {
500
+ this.props.onError?.(error, info);
501
+ } catch {
502
+ /* documented: a logger must not take the fallback down */
503
+ }
504
+ const details = this.state.details ?? describeThrown(error);
505
+ const report = buildReport(details, info.componentStack ?? undefined, this.props);
506
+ const { onReport } = this.props;
507
+ const willFile = onReport !== undefined && !report.chunkLoad;
508
+ const generation = ++this.generation;
509
+ this.setState({ report, filing: willFile ? "pending" : "none", reference: undefined });
510
+ if (!willFile) return;
511
+ void fileOnce(report, onReport).then((filing) => {
512
+ if (generation !== this.generation) return;
513
+ this.setState(
514
+ filing.filed ? { filing: "filed", reference: filing.reference } : { filing: "failed", reference: undefined },
515
+ );
516
+ });
136
517
  }
137
518
 
138
519
  componentDidUpdate(prev: ErrorBoundaryProps, prevState: ErrorBoundaryState): void {
@@ -148,21 +529,28 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
148
529
  }
149
530
 
150
531
  reset = (): void => {
532
+ this.generation++;
151
533
  this.props.onReset?.();
152
534
  this.setState(CLEAR);
153
535
  };
154
536
 
155
537
  render(): ReactNode {
156
- const { failed, error, details } = this.state;
538
+ const { failed, error, details, report, filing, reference } = this.state;
157
539
  if (!failed || !details) return this.props.children;
158
540
  const { fallback } = this.props;
159
- if (typeof fallback === "function") return fallback({ error, details, reset: this.reset });
541
+ if (typeof fallback === "function") return fallback({ error, details, reset: this.reset, report });
160
542
  if (fallback !== undefined) return fallback;
161
543
  return (
162
544
  <ErrorFallback
163
545
  details={details}
546
+ report={report}
547
+ filing={filing}
548
+ reference={reference}
164
549
  reset={this.reset}
165
- showDetails={this.props.showDetails ?? false}
550
+ showMessage={this.props.showMessage ?? true}
551
+ showDetails={this.props.showDetails ?? true}
552
+ copyReport={this.props.copyReport ?? true}
553
+ actions={this.props.actions}
166
554
  headingAs={this.props.headingAs ?? "h2"}
167
555
  className={this.props.className}
168
556
  labels={this.props.labels}
@@ -173,39 +561,91 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
173
561
 
174
562
  function ErrorFallback({
175
563
  details,
564
+ report,
565
+ filing,
566
+ reference,
176
567
  reset,
568
+ showMessage,
177
569
  showDetails,
570
+ copyReport,
571
+ actions,
178
572
  headingAs,
179
573
  className,
180
574
  labels,
181
575
  }: {
182
576
  details: ErrorBoundaryDetails;
577
+ report: CrashReport | null;
578
+ filing: FilingState;
579
+ reference: string | undefined;
183
580
  reset: () => void;
581
+ showMessage: boolean;
184
582
  showDetails: boolean;
583
+ copyReport: boolean;
584
+ actions?: ReactNode;
185
585
  headingAs: NonNullable<ErrorBoundaryProps["headingAs"]>;
186
586
  className?: string;
187
587
  labels?: Partial<ErrorBoundaryLabels>;
188
588
  }) {
189
589
  const text = useKitLabels("errorBoundary", DEFAULT_ERROR_BOUNDARY_LABELS, labels);
590
+ const chunk = isChunkLoadError(details);
591
+ // Offline first, as keksdose has it: offline, a chunk failure is the connection, and
592
+ // any other crash most likely a request that could not be made — either way "reload
593
+ // once you are back" is the advice, and "a new version" would be a false one.
594
+ const mode = isOffline() ? "offline" : chunk ? "update" : "error";
595
+ const title = mode === "offline" ? text.offlineTitle : mode === "update" ? text.updateTitle : text.title;
596
+ const hint = mode === "offline" ? text.offlineMessage : mode === "update" ? text.updateMessage : text.message;
597
+ const Icon = mode === "offline" ? WifiOff : mode === "update" ? RefreshCw : AlertTriangle;
598
+ const reportText = report ? formatCrashReport(report) : null;
599
+ const errorLine = details.message ? `${details.name}: ${details.message}` : details.name;
600
+
601
+ const reloadButton = (
602
+ <Button key="reload" variant={mode === "error" ? "secondary" : "primary"} onClick={reloadPage}>
603
+ {text.reload}
604
+ </Button>
605
+ );
606
+ // Not after a chunk failure: `React.lazy` keeps the rejected import, so Try again
607
+ // could only throw the same error again.
608
+ const retryButton = chunk ? null : (
609
+ <Button key="retry" variant={mode === "error" ? "primary" : "secondary"} onClick={reset}>
610
+ {text.retry}
611
+ </Button>
612
+ );
613
+
190
614
  return (
191
615
  <EmptyState
192
616
  role="alert"
193
- tone="danger"
617
+ tone={mode === "error" ? "danger" : undefined}
194
618
  headingAs={headingAs}
195
619
  className={className}
196
- icon={<AlertTriangle />}
197
- title={text.title}
198
- hint={text.message}
620
+ icon={<Icon />}
621
+ title={title}
622
+ hint={hint}
199
623
  action={
200
624
  <>
201
- <Button variant="secondary" onClick={reset}>
202
- {text.retry}
203
- </Button>
204
- {showDetails && (
625
+ {showMessage && mode === "error" && (
626
+ // A quotation, not prose: left-aligned and monospaced, so it reads as the
627
+ // program's words and can be read out or screenshotted verbatim.
628
+ <p
629
+ data-error-message=""
630
+ className="-mt-1 mb-1 basis-full whitespace-pre-wrap break-words rounded-md border border-[var(--danger-border)] bg-[var(--danger-bg)] px-3 py-2 text-start font-mono text-xs text-[var(--text-primary)]"
631
+ >
632
+ {errorLine}
633
+ </p>
634
+ )}
635
+ {filing === "filed" && (
636
+ <p className="-mt-1 mb-1 basis-full text-xs">
637
+ {reference ? text.reportedAs(reference) : text.reported}
638
+ </p>
639
+ )}
640
+ {mode === "error" ? [retryButton, reloadButton] : [reloadButton, retryButton]}
641
+ {copyReport && reportText !== null && filing !== "filed" && (
642
+ <CopyButton variant="label" buttonVariant="ghost" label={text.copyReport} text={reportText} />
643
+ )}
644
+ {actions}
645
+ {showDetails && reportText !== null && (
205
646
  <Disclosure variant="bare" title={text.details} className="basis-full text-start">
206
647
  <pre className="max-h-60 overflow-auto whitespace-pre-wrap break-words text-xs text-[var(--text-secondary)]">
207
- {details.message ? `${details.name}: ${details.message}` : details.name}
208
- {details.stack ? `\n\n${details.stack}` : null}
648
+ {reportText}
209
649
  </pre>
210
650
  </Disclosure>
211
651
  )}