@djangocfg/ui-core 2.1.381 → 2.1.383

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 (78) hide show
  1. package/README.md +85 -21
  2. package/package.json +5 -12
  3. package/src/components/boundary/Boundary.tsx +204 -33
  4. package/src/components/boundary/README.md +249 -0
  5. package/src/components/boundary/index.ts +9 -2
  6. package/src/components/index.ts +9 -2
  7. package/src/components/select/combobox.tsx +47 -19
  8. package/src/hooks/audio/createSoundBus.ts +172 -0
  9. package/src/hooks/audio/index.ts +21 -0
  10. package/src/hooks/audio/useAudioPrefs.ts +91 -0
  11. package/src/hooks/audio/useNotificationSounds.ts +271 -0
  12. package/src/hooks/audio/useSoundEffect.ts +78 -0
  13. package/src/hooks/hotkey/formatHotkey.ts +96 -0
  14. package/src/hooks/hotkey/index.ts +10 -0
  15. package/src/hooks/hotkey/useHotkey.ts +106 -34
  16. package/src/hooks/hotkey/useHotkeyChord.ts +96 -0
  17. package/src/hooks/hotkey/useHotkeyHelp.ts +68 -0
  18. package/src/hooks/index.ts +1 -0
  19. package/src/components/boundary/boundary.story.tsx +0 -109
  20. package/src/components/data/avatar/avatar.story.tsx +0 -115
  21. package/src/components/data/badge/badge.story.tsx +0 -56
  22. package/src/components/data/calendar/calendar.story.tsx +0 -127
  23. package/src/components/data/carousel/carousel.story.tsx +0 -122
  24. package/src/components/data/progress/progress.story.tsx +0 -97
  25. package/src/components/data/table/table.story.tsx +0 -148
  26. package/src/components/data/toggle/toggle.story.tsx +0 -104
  27. package/src/components/data/toggle-group/toggle-group.story.tsx +0 -118
  28. package/src/components/feedback/alert/alert.story.tsx +0 -77
  29. package/src/components/feedback/empty/empty.story.tsx +0 -115
  30. package/src/components/feedback/preloader/preloader.story.tsx +0 -86
  31. package/src/components/feedback/spinner/spinner.story.tsx +0 -66
  32. package/src/components/forms/button/button.story.tsx +0 -116
  33. package/src/components/forms/button-download/button-download.story.tsx +0 -112
  34. package/src/components/forms/button-group/button-group.story.tsx +0 -79
  35. package/src/components/forms/checkbox/checkbox.story.tsx +0 -89
  36. package/src/components/forms/input/input.story.tsx +0 -77
  37. package/src/components/forms/input-group/input-group.story.tsx +0 -119
  38. package/src/components/forms/input-otp/input-otp.story.tsx +0 -105
  39. package/src/components/forms/label/label.story.tsx +0 -52
  40. package/src/components/forms/radio-group/radio-group.story.tsx +0 -113
  41. package/src/components/forms/slider/slider.story.tsx +0 -134
  42. package/src/components/forms/switch/switch.story.tsx +0 -98
  43. package/src/components/forms/textarea/textarea.story.tsx +0 -94
  44. package/src/components/layout/aspect-ratio/aspect-ratio.story.tsx +0 -94
  45. package/src/components/layout/card/card.story.tsx +0 -105
  46. package/src/components/layout/resizable/resizable.story.tsx +0 -119
  47. package/src/components/layout/scroll-area/scroll-area.story.tsx +0 -172
  48. package/src/components/layout/separator/separator.story.tsx +0 -69
  49. package/src/components/layout/skeleton/skeleton.story.tsx +0 -101
  50. package/src/components/navigation/accordion/accordion.story.tsx +0 -110
  51. package/src/components/navigation/collapsible/collapsible.story.tsx +0 -133
  52. package/src/components/navigation/command/command.story.tsx +0 -121
  53. package/src/components/navigation/context-menu/context-menu.story.tsx +0 -125
  54. package/src/components/navigation/dropdown-menu/dropdown-menu.story.tsx +0 -208
  55. package/src/components/navigation/menubar/menubar.story.tsx +0 -152
  56. package/src/components/navigation/navigation-menu/navigation-menu.story.tsx +0 -154
  57. package/src/components/navigation/tabs/tabs.story.tsx +0 -98
  58. package/src/components/overlay/alert-dialog/alert-dialog.story.tsx +0 -104
  59. package/src/components/overlay/dialog/dialog.story.tsx +0 -212
  60. package/src/components/overlay/drawer/drawer.story.tsx +0 -359
  61. package/src/components/overlay/hover-card/hover-card.story.tsx +0 -102
  62. package/src/components/overlay/popover/popover.story.tsx +0 -127
  63. package/src/components/overlay/responsive-sheet/responsive-sheet.story.tsx +0 -117
  64. package/src/components/overlay/sheet/sheet.story.tsx +0 -148
  65. package/src/components/overlay/tooltip/tooltip.story.tsx +0 -139
  66. package/src/components/select/combobox-async.story.tsx +0 -215
  67. package/src/components/select/combobox.story.tsx +0 -226
  68. package/src/components/select/country-select.story.tsx +0 -261
  69. package/src/components/select/language-select.story.tsx +0 -264
  70. package/src/components/select/multi-select.story.tsx +0 -122
  71. package/src/components/select/select.story.tsx +0 -112
  72. package/src/components/specialized/copy/copy.story.tsx +0 -77
  73. package/src/components/specialized/flag/flag.story.tsx +0 -82
  74. package/src/components/specialized/image-with-fallback/image-with-fallback.story.tsx +0 -105
  75. package/src/components/specialized/kbd/kbd.story.tsx +0 -113
  76. package/src/lib/dialog-service/dialog-service.story.tsx +0 -263
  77. package/src/stories/index.ts +0 -28
  78. package/src/styles/theme/theme-tokens.story.tsx +0 -157
package/README.md CHANGED
@@ -43,33 +43,19 @@ Organized in `components/` by category — everything re-exported from the root
43
43
 
44
44
  ### Boundary
45
45
 
46
- Wrap untrusted subtrees so a single render error does not crash the whole page.
47
-
48
46
  ```tsx
49
- import { Boundary } from '@djangocfg/ui-core';
47
+ import { Boundary, useBoundary } from '@djangocfg/ui-core';
50
48
 
51
- // non-critical widget — disappears on error, page stays alive
52
49
  <Boundary variant="silent"><ChatLauncher /></Boundary>
50
+ <Boundary variant="card" resetKeys={[pathname]}><Panel /></Boundary>
51
+ ```
53
52
 
54
- // inline status compact alert with Retry
55
- <Boundary variant="inline" name="catalog-row"><Row /></Boundary>
56
-
57
- // feature panel — card with Retry button (default)
58
- <Boundary><AnalyticsPanel /></Boundary>
53
+ Variants: `silent` / `inline` / `card` / `fullscreen`. Plus `useBoundary()` hook for async errors, `resetKeys`, `onReset`, custom `fallback` / `FallbackComponent`, and safe re-render guarantees.
59
54
 
60
- // fullscreen for top-level layouts
61
- <Boundary variant="fullscreen"><AppShell /></Boundary>
55
+ Full docs and patterns: [`src/components/boundary/README.md`](./src/components/boundary/README.md).
62
56
 
63
- // custom fallback + auto-reset on route change
64
- <Boundary
65
- resetKeys={[pathname]}
66
- fallback={({ error, reset }) => <MyErrorCard error={error} onRetry={reset} />}
67
- >
68
- <Page />
69
- </Boundary>
70
- ```
57
+ For automatic backend reporting use `MonitorBoundary` from `@djangocfg/layouts`.
71
58
 
72
- For automatic reporting to your backend, use `MonitorBoundary` from `@djangocfg/layouts` (it wraps `Boundary` and pushes events through `@djangocfg/monitor/client`).
73
59
 
74
60
  > Pagination, breadcrumb and sidebar live here (not in ui-nextjs). `SSRPagination` reads URL state through `useLocation` + `useQueryParams`, so it works under any router adapter.
75
61
 
@@ -88,7 +74,8 @@ import { useNavigate, useLocation, useQueryParams, useRouter, useIsActive } from
88
74
  | **State** | `useDebounce`, `useDebouncedCallback`, `useCountdown`, `useImageLoader`, `useMounted` |
89
75
  | **DOM** | `useEventListener` |
90
76
  | **Theme** | `useThemeColor`, `useThemePalette` (palette-aware hex colors for Canvas/SVG) |
91
- | **Hotkey** | `useHotkey`, `HotkeysProvider`, `useHotkeysContext` |
77
+ | **Hotkey** | `useHotkey` (smart `inInput` + `preventDefault` policy), `useHotkeyChord` (sequences), `formatHotkey('mod+k') → ⌘K`, `useHotkeyHelp` (auto cheat-sheet) |
78
+ | **Audio** | `createSoundBus`, `useNotificationSounds`, `useAudioPrefs`, `useSoundEffect` — Safari unlock, mute persist, per-event toggles + volume scale, native-host bridge |
92
79
  | **Feedback** | `useToast`, `toast` (Sonner) |
93
80
  | **Debug** | `useDebugTools` |
94
81
 
@@ -106,6 +93,83 @@ import NextLink from 'next/link';
106
93
 
107
94
  When no adapter is mounted, `<Link>` falls back to a plain `<a>` and routes clicks through the History API. `@djangocfg/layouts/BaseApp` mounts the Next adapters by default.
108
95
 
96
+ ## Hotkeys
97
+
98
+ ```tsx
99
+ import { useHotkey, formatHotkey, useHotkeyChord } from '@djangocfg/ui-core/hooks';
100
+
101
+ // Smart defaults — auto-detects modifiers vs bare keys:
102
+ useHotkey('mod+k', openPalette); // ⌘K everywhere, incl. inputs; preventDefault auto
103
+ useHotkey('/', focusSearch); // bare key — skipped inside inputs
104
+ useHotkey('escape', closeModal); // always fires in inputs (blur / close pattern)
105
+ useHotkey('?', openHelp, { description: 'Show shortcuts' }); // registers in cheat-sheet
106
+
107
+ // Linear-style chords:
108
+ useHotkeyChord(['g', 't'], () => router.push('/tasks'));
109
+
110
+ // OS-aware tooltips:
111
+ formatHotkey('mod+/') // → '⌘/' on Mac, 'Ctrl+/' elsewhere
112
+ formatHotkey('g t') // → 'G then T'
113
+ ```
114
+
115
+ Built on `react-hotkeys-hook` with an opinionated policy: modifier-combos and `escape` fire inside `<input>` / `<textarea>` / `[contenteditable]` by default; bare keys don't, so they won't hijack typing. Override per-call with `inInput: true | false`.
116
+
117
+ `useHotkeyHelp()` reads a module-level registry populated by every `useHotkey(..., { description })` call — drop it into a `?` cheat-sheet dialog with two lines.
118
+
119
+ ## Audio
120
+
121
+ Notification sounds with Safari unlock, persisted mute, and a side-channel for native hosts.
122
+
123
+ ```tsx
124
+ import { useNotificationSounds, useSoundEffect } from '@djangocfg/ui-core/hooks';
125
+
126
+ // Map event → URL + persisted mute + per-event toggles
127
+ type Event = 'received' | 'mention' | 'error';
128
+ const sounds = useNotificationSounds<Event>({
129
+ storageKey: 'myapp.audio',
130
+ sounds: { received: '/sfx/r.mp3', mention: '/sfx/m.mp3', error: '/sfx/e.mp3' },
131
+ });
132
+
133
+ onMessage = (m) => sounds.play('received');
134
+ toggle = () => sounds.toggleMute();
135
+ sounds.muted // boolean, persisted
136
+ sounds.isSilent // true if no sounds wired or silenced
137
+
138
+ // One-shot single asset (no persistence, no toggles)
139
+ const ding = useSoundEffect('/sfx/ding.mp3');
140
+ <Button onClick={() => { ding.play(); submit(); }} />
141
+ ```
142
+
143
+ **Native-host bridge.** When a wrapper (Electron / Wails / Tauri) plays sounds outside the browser, set `silenced: true` and pipe `onSoundEvent` to your bridge — web playback stays silent while the backend gets the trigger:
144
+
145
+ ```tsx
146
+ useNotificationSounds({
147
+ storageKey: 'cmdop.audio',
148
+ silenced: true,
149
+ onSoundEvent: (event) => window.go.playSound(event),
150
+ });
151
+ ```
152
+
153
+ **Per-event volume scale.** Master volume is multiplied by an optional per-event factor so different sounds can be balanced without juggling source files:
154
+
155
+ ```tsx
156
+ useNotificationSounds<Event>({
157
+ storageKey: 'myapp.audio',
158
+ sounds: { received: '/r.mp3', error: '/e.mp3', mention: '/m.mp3' },
159
+ eventVolumes: {
160
+ error: 0.25, // soft ack — destructive UI is the loud signal
161
+ mention: 1, // personal — louder than baseline
162
+ received: 0.7,
163
+ },
164
+ });
165
+ ```
166
+
167
+ The bus respects `prefers-reduced-motion`, `prefers-reduced-data`, and `visibilityState === 'hidden'` by default (each is an opt-out). Multi-component sync: same `storageKey` from different surfaces shares the same store.
168
+
169
+ Lower level: `createSoundBus<E>({ sounds, getMuted, getVolume, isEnabled })` exposes the raw bus if you need to wire your own React state. `getVolume(event)` receives the event so you can scale per-fire.
170
+
171
+ For an opinionated, fully-wired example with bundled audio assets see `useChatAudio` in [`@djangocfg/ui-tools`](../../ui-tools/src/tools/Chat/README.md#audio) — it inlines six notification mp3s into the lazy chat chunk as `data:`-URLs so consumers need no asset setup.
172
+
109
173
  ## Schema-driven configurators
110
174
 
111
175
  `@djangocfg/ui-core/lib` exports a portable JSON Schema 7 subset (`CustomJsonSchema7`, `CustomJsonUiSchema7`, `CustomJsonUiGroup`, `CustomJsonUiDisabledWhenRule`) for packages that ship configurator schemas without taking a runtime dependency on RJSF.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-core",
3
- "version": "2.1.381",
3
+ "version": "2.1.383",
4
4
  "description": "Pure React UI component library without Next.js dependencies - for Electron, Vite, CRA apps",
5
5
  "keywords": [
6
6
  "ui-components",
@@ -33,11 +33,6 @@
33
33
  "import": "./src/index.ts",
34
34
  "require": "./src/index.ts"
35
35
  },
36
- "./stories": {
37
- "types": "./src/stories/index.ts",
38
- "import": "./src/stories/index.ts",
39
- "require": "./src/stories/index.ts"
40
- },
41
36
  "./components": {
42
37
  "types": "./src/components/index.ts",
43
38
  "import": "./src/components/index.ts",
@@ -92,11 +87,10 @@
92
87
  ],
93
88
  "scripts": {
94
89
  "lint": "eslint .",
95
- "check": "tsc --noEmit",
96
- "playground": "playground dev"
90
+ "check": "tsc --noEmit"
97
91
  },
98
92
  "peerDependencies": {
99
- "@djangocfg/i18n": "^2.1.381",
93
+ "@djangocfg/i18n": "^2.1.383",
100
94
  "consola": "^3.4.2",
101
95
  "lucide-react": "^0.545.0",
102
96
  "moment": "^2.30.1",
@@ -166,9 +160,8 @@
166
160
  "vaul": "1.1.2"
167
161
  },
168
162
  "devDependencies": {
169
- "@djangocfg/i18n": "^2.1.381",
170
- "@djangocfg/playground": "workspace:*",
171
- "@djangocfg/typescript-config": "^2.1.381",
163
+ "@djangocfg/i18n": "^2.1.383",
164
+ "@djangocfg/typescript-config": "^2.1.383",
172
165
  "@types/node": "^24.7.2",
173
166
  "@types/react": "^19.1.0",
174
167
  "@types/react-dom": "^19.1.0",
@@ -1,7 +1,14 @@
1
1
  'use client';
2
2
 
3
3
  import { AlertTriangle, RotateCcw } from 'lucide-react';
4
- import { Component, type ErrorInfo, type ReactNode } from 'react';
4
+ import {
5
+ Component,
6
+ createContext,
7
+ useContext,
8
+ type ComponentType,
9
+ type ErrorInfo,
10
+ type ReactNode,
11
+ } from 'react';
5
12
 
6
13
  import { isDev } from '../../lib/env';
7
14
  import { cn } from '../../lib/utils';
@@ -11,16 +18,29 @@ export type BoundaryVariant = 'silent' | 'inline' | 'card' | 'fullscreen';
11
18
 
12
19
  export interface BoundaryRenderProps {
13
20
  error: Error;
21
+ errorInfo: ErrorInfo | null;
14
22
  reset: () => void;
15
23
  }
16
24
 
25
+ export type BoundaryResetReason = 'imperative' | 'keys';
26
+
27
+ export interface BoundaryResetDetails {
28
+ reason: BoundaryResetReason;
29
+ prevResetKeys?: ReadonlyArray<unknown>;
30
+ nextResetKeys?: ReadonlyArray<unknown>;
31
+ }
32
+
33
+ export interface BoundaryLogger {
34
+ (message: string, error: Error, info: ErrorInfo | null): void;
35
+ }
36
+
17
37
  export interface BoundaryProps {
18
38
  children: ReactNode;
19
39
  /**
20
40
  * Visual style of the fallback.
21
41
  * - silent: render nothing (good for non-critical widgets like a chat launcher)
22
42
  * - inline: compact one-line warning (good for inline blocks inside a page)
23
- * - card: bordered card with retry button (good for panels/features)
43
+ * - card: bordered card with retry button (default; good for panels/features)
24
44
  * - fullscreen: centered fullscreen fallback (good for top-level layout)
25
45
  * @default 'card'
26
46
  */
@@ -28,29 +48,76 @@ export interface BoundaryProps {
28
48
  /**
29
49
  * Custom fallback. Receives the caught error and a reset() function.
30
50
  * Overrides `variant` rendering when provided.
51
+ * Takes precedence over `FallbackComponent`.
31
52
  */
32
53
  fallback?: ReactNode | ((props: BoundaryRenderProps) => ReactNode);
33
54
  /**
34
- * Auto-reset the boundary when any of these values change.
35
- * Common use: pass the current pathname or a feature id.
55
+ * Custom fallback component. Use this form instead of `fallback` for
56
+ * better memoization (no inline closures).
57
+ */
58
+ FallbackComponent?: ComponentType<BoundaryRenderProps>;
59
+ /**
60
+ * Auto-reset the boundary when any of these values change (shallow Object.is per index).
61
+ * Common use: pass `[pathname]` to clear errors on route change.
62
+ * AVOID passing volatile values like `Math.random()` or new object/array literals — causes reset loops.
36
63
  */
37
64
  resetKeys?: ReadonlyArray<unknown>;
38
65
  /**
39
- * Called when an error is caught. Hook up Sentry / logging here.
66
+ * Called when an error is caught.
67
+ * Hook up Sentry / logging / @djangocfg/monitor here.
68
+ * Use the wrapping `MonitorBoundary` from @djangocfg/layouts for automatic reporting.
40
69
  */
41
70
  onError?: (error: Error, info: ErrorInfo) => void;
71
+ /**
72
+ * Called when the boundary recovers (via Retry button or resetKeys change).
73
+ * Use it to refetch data / invalidate caches (e.g. React Query queryClient.invalidateQueries).
74
+ */
75
+ onReset?: (details: BoundaryResetDetails) => void;
42
76
  /**
43
77
  * Optional label shown in dev console logs to help locate the source.
44
78
  */
45
79
  name?: string;
46
80
  /**
47
- * Extra className for the fallback wrapper (variant: inline / card).
81
+ * Extra className for the fallback wrapper (variant: inline / card / fullscreen).
48
82
  */
49
83
  className?: string;
84
+ /**
85
+ * Replace default dev console logger. Defaults to `console.error` in development, no-op in production.
86
+ */
87
+ logger?: BoundaryLogger;
50
88
  }
51
89
 
52
90
  interface BoundaryState {
53
91
  error: Error | null;
92
+ errorInfo: ErrorInfo | null;
93
+ }
94
+
95
+ interface BoundaryContextValue {
96
+ /** Programmatically push an error into the nearest boundary (use for async / event handler errors). */
97
+ showBoundary: (error: unknown) => void;
98
+ /** Programmatically clear the boundary (same as the Retry button). */
99
+ resetBoundary: () => void;
100
+ }
101
+
102
+ const BoundaryContext = createContext<BoundaryContextValue | null>(null);
103
+
104
+ /**
105
+ * Access the nearest `<Boundary>` programmatically.
106
+ * Lets you funnel errors from async code / event handlers into the boundary
107
+ * (regular React error boundaries don't catch those automatically).
108
+ *
109
+ * @example
110
+ * const { showBoundary } = useBoundary();
111
+ * useEffect(() => {
112
+ * fetchData().catch(showBoundary);
113
+ * }, [showBoundary]);
114
+ */
115
+ export function useBoundary(): BoundaryContextValue {
116
+ const ctx = useContext(BoundaryContext);
117
+ if (!ctx) {
118
+ throw new Error('useBoundary must be used inside <Boundary>');
119
+ }
120
+ return ctx;
54
121
  }
55
122
 
56
123
  function arraysShallowEqual(a: ReadonlyArray<unknown>, b: ReadonlyArray<unknown>): boolean {
@@ -61,19 +128,57 @@ function arraysShallowEqual(a: ReadonlyArray<unknown>, b: ReadonlyArray<unknown>
61
128
  return true;
62
129
  }
63
130
 
131
+ function toError(value: unknown): Error {
132
+ if (value instanceof Error) return value;
133
+ if (typeof value === 'string') return new Error(value);
134
+ try {
135
+ return new Error(JSON.stringify(value));
136
+ } catch {
137
+ return new Error('Unknown error');
138
+ }
139
+ }
140
+
141
+ const defaultLogger: BoundaryLogger = (message, error, info) => {
142
+ if (!isDev) return;
143
+ console.error(message, error, info ?? '');
144
+ };
145
+
64
146
  export class Boundary extends Component<BoundaryProps, BoundaryState> {
65
- state: BoundaryState = { error: null };
147
+ state: BoundaryState = { error: null, errorInfo: null };
148
+
149
+ /** Stable context value identity — recreated only when error/reset functions change (i.e. on mount). */
150
+ private contextValue: BoundaryContextValue = {
151
+ showBoundary: (error: unknown) => this.showBoundary(error),
152
+ resetBoundary: () => this.reset('imperative'),
153
+ };
154
+
155
+ /** Prevents `onError` from firing twice for the same error instance. */
156
+ private lastReportedError: Error | null = null;
66
157
 
67
- static getDerivedStateFromError(error: Error): BoundaryState {
68
- return { error };
158
+ static getDerivedStateFromError(error: unknown): BoundaryState {
159
+ return { error: toError(error), errorInfo: null };
69
160
  }
70
161
 
71
162
  componentDidCatch(error: Error, info: ErrorInfo) {
72
- this.props.onError?.(error, info);
73
- if (isDev) {
74
- const tag = this.props.name ? `[Boundary:${this.props.name}]` : '[Boundary]';
75
- console.error(tag, error, info);
163
+ const normalized = toError(error);
164
+ this.setState({ errorInfo: info });
165
+
166
+ if (this.lastReportedError === normalized) return;
167
+ this.lastReportedError = normalized;
168
+
169
+ try {
170
+ this.props.onError?.(normalized, info);
171
+ } catch (callbackErr) {
172
+ // Never let user's onError crash the boundary.
173
+ (this.props.logger ?? defaultLogger)(
174
+ `[Boundary${this.props.name ? `:${this.props.name}` : ''}] onError handler threw`,
175
+ toError(callbackErr),
176
+ null,
177
+ );
76
178
  }
179
+
180
+ const tag = this.props.name ? `[Boundary:${this.props.name}]` : '[Boundary]';
181
+ (this.props.logger ?? defaultLogger)(`${tag} caught error`, normalized, info);
77
182
  }
78
183
 
79
184
  componentDidUpdate(prevProps: BoundaryProps) {
@@ -81,28 +186,92 @@ export class Boundary extends Component<BoundaryProps, BoundaryState> {
81
186
  const prevKeys = prevProps.resetKeys;
82
187
  const nextKeys = this.props.resetKeys;
83
188
  if (prevKeys && nextKeys && !arraysShallowEqual(prevKeys, nextKeys)) {
84
- this.reset();
189
+ this.reset('keys', prevKeys, nextKeys);
85
190
  }
86
191
  }
87
192
 
88
- reset = () => {
89
- this.setState({ error: null });
193
+ /** Imperatively raise an error from async code / event handlers via `useBoundary()`. */
194
+ showBoundary = (raw: unknown) => {
195
+ const error = toError(raw);
196
+ this.setState({ error, errorInfo: null });
90
197
  };
91
198
 
92
- render() {
93
- const { error } = this.state;
94
- if (!error) return this.props.children;
199
+ reset = (
200
+ reason: BoundaryResetReason = 'imperative',
201
+ prevResetKeys?: ReadonlyArray<unknown>,
202
+ nextResetKeys?: ReadonlyArray<unknown>,
203
+ ) => {
204
+ // Clear state BEFORE calling onReset — otherwise an error thrown
205
+ // inside onReset would re-trigger the boundary before state is cleared.
206
+ this.lastReportedError = null;
207
+ this.setState({ error: null, errorInfo: null });
95
208
 
96
- const { fallback, variant = 'card', className } = this.props;
209
+ // Defer onReset to a microtask so the state clear is flushed first.
210
+ queueMicrotask(() => {
211
+ try {
212
+ this.props.onReset?.({ reason, prevResetKeys, nextResetKeys });
213
+ } catch (err) {
214
+ (this.props.logger ?? defaultLogger)(
215
+ `[Boundary${this.props.name ? `:${this.props.name}` : ''}] onReset handler threw`,
216
+ toError(err),
217
+ null,
218
+ );
219
+ }
220
+ });
221
+ };
97
222
 
98
- if (typeof fallback === 'function') {
99
- return fallback({ error, reset: this.reset });
100
- }
101
- if (fallback !== undefined) {
102
- return fallback;
223
+ /** Wraps fallback rendering so errors in the fallback itself can't crash the boundary. */
224
+ private safeRenderFallback(): ReactNode {
225
+ const { error, errorInfo } = this.state;
226
+ if (!error) return null;
227
+
228
+ const { fallback, FallbackComponent, variant = 'card', className } = this.props;
229
+
230
+ try {
231
+ if (typeof fallback === 'function') {
232
+ return fallback({ error, errorInfo, reset: () => this.reset('imperative') });
233
+ }
234
+ if (fallback !== undefined) {
235
+ return fallback;
236
+ }
237
+ if (FallbackComponent) {
238
+ return (
239
+ <FallbackComponent
240
+ error={error}
241
+ errorInfo={errorInfo}
242
+ reset={() => this.reset('imperative')}
243
+ />
244
+ );
245
+ }
246
+ return renderVariant(variant, error, () => this.reset('imperative'), className);
247
+ } catch (fallbackError) {
248
+ // Last-resort static fallback — prevents infinite loops if user fallback throws.
249
+ (this.props.logger ?? defaultLogger)(
250
+ `[Boundary${this.props.name ? `:${this.props.name}` : ''}] fallback render threw`,
251
+ toError(fallbackError),
252
+ null,
253
+ );
254
+ return (
255
+ <div
256
+ role="alert"
257
+ aria-live="assertive"
258
+ className="p-2 text-sm text-destructive border border-destructive/40 rounded"
259
+ >
260
+ Something went wrong, and the error fallback also failed to render.
261
+ </div>
262
+ );
103
263
  }
264
+ }
104
265
 
105
- return renderVariant(variant, error, this.reset, className);
266
+ render() {
267
+ if (this.state.error) {
268
+ return this.safeRenderFallback();
269
+ }
270
+ return (
271
+ <BoundaryContext.Provider value={this.contextValue}>
272
+ {this.props.children}
273
+ </BoundaryContext.Provider>
274
+ );
106
275
  }
107
276
  }
108
277
 
@@ -120,15 +289,14 @@ function renderVariant(
120
289
  return (
121
290
  <div
122
291
  role="alert"
292
+ aria-live="assertive"
123
293
  className={cn(
124
294
  'flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive',
125
295
  className,
126
296
  )}
127
297
  >
128
298
  <AlertTriangle className="h-4 w-4 shrink-0" />
129
- <span className="flex-1 truncate">
130
- {message ?? 'Something went wrong.'}
131
- </span>
299
+ <span className="flex-1 truncate">{message ?? 'Something went wrong.'}</span>
132
300
  <button
133
301
  type="button"
134
302
  onClick={reset}
@@ -143,7 +311,11 @@ function renderVariant(
143
311
 
144
312
  if (variant === 'fullscreen') {
145
313
  return (
146
- <div className={cn('flex min-h-screen items-center justify-center bg-background p-4', className)}>
314
+ <div
315
+ role="alert"
316
+ aria-live="assertive"
317
+ className={cn('flex min-h-screen items-center justify-center bg-background p-4', className)}
318
+ >
147
319
  <div className="max-w-md w-full space-y-4 text-center">
148
320
  <AlertTriangle className="mx-auto h-10 w-10 text-destructive" />
149
321
  <h1 className="text-2xl font-bold text-foreground">Something went wrong</h1>
@@ -171,6 +343,7 @@ function renderVariant(
171
343
  return (
172
344
  <div
173
345
  role="alert"
346
+ aria-live="assertive"
174
347
  className={cn(
175
348
  'rounded-[var(--radius)] border border-destructive/40 bg-destructive/5 p-4 text-sm',
176
349
  className,
@@ -180,9 +353,7 @@ function renderVariant(
180
353
  <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
181
354
  <div className="flex-1 space-y-2">
182
355
  <p className="font-medium text-destructive">Something went wrong</p>
183
- {message && (
184
- <p className="text-xs text-muted-foreground break-words">{message}</p>
185
- )}
356
+ {message && <p className="text-xs text-muted-foreground break-words">{message}</p>}
186
357
  <Button size="sm" variant="outline" onClick={reset}>
187
358
  <RotateCcw className="mr-2 h-3 w-3" />
188
359
  Try again