@djangocfg/ui-core 2.1.381 → 2.1.382
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/README.md +85 -21
- package/package.json +4 -4
- package/src/components/boundary/Boundary.tsx +204 -33
- package/src/components/boundary/README.md +249 -0
- package/src/components/boundary/boundary.story.tsx +87 -5
- package/src/components/boundary/index.ts +9 -2
- package/src/components/index.ts +9 -2
- package/src/components/select/combobox.tsx +47 -19
- package/src/hooks/audio/createSoundBus.ts +172 -0
- package/src/hooks/audio/index.ts +21 -0
- package/src/hooks/audio/useAudioPrefs.ts +91 -0
- package/src/hooks/audio/useNotificationSounds.ts +271 -0
- package/src/hooks/audio/useSoundEffect.ts +78 -0
- package/src/hooks/hotkey/formatHotkey.ts +96 -0
- package/src/hooks/hotkey/index.ts +10 -0
- package/src/hooks/hotkey/useHotkey.ts +106 -34
- package/src/hooks/hotkey/useHotkeyChord.ts +96 -0
- package/src/hooks/hotkey/useHotkeyHelp.ts +68 -0
- package/src/hooks/index.ts +1 -0
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
|
-
|
|
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
|
-
|
|
61
|
-
<Boundary variant="fullscreen"><AppShell /></Boundary>
|
|
55
|
+
Full docs and patterns: [`src/components/boundary/README.md`](./src/components/boundary/README.md).
|
|
62
56
|
|
|
63
|
-
|
|
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
|
|
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.
|
|
3
|
+
"version": "2.1.382",
|
|
4
4
|
"description": "Pure React UI component library without Next.js dependencies - for Electron, Vite, CRA apps",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ui-components",
|
|
@@ -96,7 +96,7 @@
|
|
|
96
96
|
"playground": "playground dev"
|
|
97
97
|
},
|
|
98
98
|
"peerDependencies": {
|
|
99
|
-
"@djangocfg/i18n": "^2.1.
|
|
99
|
+
"@djangocfg/i18n": "^2.1.382",
|
|
100
100
|
"consola": "^3.4.2",
|
|
101
101
|
"lucide-react": "^0.545.0",
|
|
102
102
|
"moment": "^2.30.1",
|
|
@@ -166,9 +166,9 @@
|
|
|
166
166
|
"vaul": "1.1.2"
|
|
167
167
|
},
|
|
168
168
|
"devDependencies": {
|
|
169
|
-
"@djangocfg/i18n": "^2.1.
|
|
169
|
+
"@djangocfg/i18n": "^2.1.382",
|
|
170
170
|
"@djangocfg/playground": "workspace:*",
|
|
171
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
171
|
+
"@djangocfg/typescript-config": "^2.1.382",
|
|
172
172
|
"@types/node": "^24.7.2",
|
|
173
173
|
"@types/react": "^19.1.0",
|
|
174
174
|
"@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 {
|
|
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
|
-
*
|
|
35
|
-
*
|
|
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.
|
|
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:
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
101
|
-
if (
|
|
102
|
-
|
|
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
|
-
|
|
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
|
|
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
|