@genesislcap/foundation-react-utils 15.50.3 → 15.51.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.
@@ -0,0 +1,217 @@
1
+ var _a;
2
+ import { __awaiter } from "tslib";
3
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
+ import { createLogger } from '@genesislcap/foundation-logger';
5
+ import { Component, useState } from 'react';
6
+ import { errorBoundaryStyles } from './error-boundary-styles';
7
+ /**
8
+ * Name of the logger the error boundaries log through. They log a caught error, with a message
9
+ * naming its scope, title, tile registration and reference ID and giving its component stack, only
10
+ * when no `onError` handler is set or when the handler throws. `setupErrorMonitoring` from foundation-utils reports
11
+ * these logs, so apps using it don't need an `onError`.
12
+ * @public
13
+ */
14
+ export const ERROR_BOUNDARY_LOGGER_NAME = 'foundation-react-utils:error-boundary';
15
+ const logger = createLogger(ERROR_BOUNDARY_LOGGER_NAME);
16
+ const initialBoundaryState = {
17
+ error: null,
18
+ componentStack: '',
19
+ capturedAt: '',
20
+ referenceId: '',
21
+ };
22
+ /**
23
+ * Marks instances of these boundaries. A brand rather than `instanceof`, so {@link onCaughtError}
24
+ * still recognises a boundary from another copy of this package, such as one in a federated remote.
25
+ */
26
+ const BOUNDARY_BRAND = Symbol.for('@genesislcap/foundation-react-utils:error-boundary');
27
+ const REPORTS_GLOBAL = '__GENESIS_ERROR_BOUNDARY_REPORTS__';
28
+ const MAX_REPORTS = 20;
29
+ const COPIED_RESET_MS = 2000;
30
+ const REFERENCE_RANDOM_LENGTH = 6;
31
+ const BASE36 = 36;
32
+ const toError = (value) => {
33
+ if (value instanceof Error) {
34
+ return value;
35
+ }
36
+ if (typeof value === 'string') {
37
+ return new Error(value);
38
+ }
39
+ try {
40
+ return new Error(JSON.stringify(value));
41
+ }
42
+ catch (_b) {
43
+ return new Error('Unknown non-serializable error');
44
+ }
45
+ };
46
+ const createReferenceId = () => {
47
+ const timestamp = new Date().toISOString().split(':').join('-');
48
+ const random = Math.random()
49
+ .toString(BASE36)
50
+ .slice(2, 2 + REFERENCE_RANDOM_LENGTH)
51
+ .toUpperCase();
52
+ return `APP-${timestamp}-${random}`;
53
+ };
54
+ /**
55
+ * Keeps the most recent reports on `window` for dev tooling (the Genesis Create preview reads them).
56
+ */
57
+ const recordReport = (error, componentStack) => {
58
+ var _b, _c;
59
+ const host = globalThis;
60
+ const reports = ((_b = host[REPORTS_GLOBAL]) !== null && _b !== void 0 ? _b : (host[REPORTS_GLOBAL] = []));
61
+ reports.push({
62
+ message: error.message,
63
+ stack: (_c = error.stack) !== null && _c !== void 0 ? _c : null,
64
+ componentStack: componentStack || null,
65
+ ts: Date.now(),
66
+ });
67
+ if (reports.length > MAX_REPORTS) {
68
+ reports.shift();
69
+ }
70
+ };
71
+ const ErrorBoundaryFallback = ({ scope, referenceId, title, subtitle, details, onRetry, }) => {
72
+ const [copied, setCopied] = useState(false);
73
+ const [copyHelpVisible, setCopyHelpVisible] = useState(false);
74
+ const handleCopy = () => __awaiter(void 0, void 0, void 0, function* () {
75
+ try {
76
+ yield navigator.clipboard.writeText(details);
77
+ setCopied(true);
78
+ setCopyHelpVisible(false);
79
+ window.setTimeout(() => setCopied(false), COPIED_RESET_MS);
80
+ }
81
+ catch (_b) {
82
+ setCopyHelpVisible(true);
83
+ }
84
+ });
85
+ return (_jsxs("section", { className: "error-boundary", role: "alert", "data-test-id": "error-boundary", children: [_jsx("style", { children: errorBoundaryStyles }), _jsxs("div", { className: "error-boundary__top", children: [_jsx("span", { className: "error-boundary__status", children: scope === 'application' ? 'Application incident' : 'Tile incident' }), _jsxs("span", { className: "error-boundary__reference", "data-test-id": "error-boundary-reference", children: ["Ref: ", referenceId] })] }), _jsx("h2", { className: "error-boundary__title", children: title }), _jsx("p", { className: "error-boundary__subtitle", children: subtitle }), _jsx("textarea", { className: "error-boundary__details", readOnly: true, value: details, "aria-label": "Error diagnostics details", "data-test-id": "error-boundary-details" }), _jsxs("div", { className: "error-boundary__actions", children: [_jsx("button", { type: "button", className: "error-boundary__button", onClick: onRetry, "data-test-id": "error-boundary-retry", children: "Retry" }), _jsx("button", { type: "button", className: "error-boundary__button error-boundary__button--secondary", onClick: handleCopy, "data-test-id": "error-boundary-copy", children: copied ? 'Copied' : 'Copy diagnostics' })] }), copyHelpVisible ? (_jsx("p", { className: "error-boundary__manual-copy-help", role: "status", children: "Clipboard access is blocked in this browser context. Select the diagnostics text and copy it manually." })) : null] }));
86
+ };
87
+ class BaseErrorBoundary extends Component {
88
+ constructor() {
89
+ super(...arguments);
90
+ this[_a] = true;
91
+ this.state = initialBoundaryState;
92
+ this.handleRetry = () => {
93
+ this.setState(initialBoundaryState);
94
+ };
95
+ }
96
+ static getDerivedStateFromError(error) {
97
+ return {
98
+ error: toError(error),
99
+ capturedAt: new Date().toISOString(),
100
+ referenceId: createReferenceId(),
101
+ };
102
+ }
103
+ componentDidCatch(error, errorInfo) {
104
+ var _b;
105
+ const { scope, title, tileRegistration, onError } = this.props;
106
+ const normalized = toError(error);
107
+ const componentStack = (_b = errorInfo.componentStack) !== null && _b !== void 0 ? _b : '';
108
+ const { referenceId } = this.state;
109
+ this.setState({ componentStack });
110
+ recordReport(normalized, componentStack);
111
+ const tile = tileRegistration ? ` (${tileRegistration})` : '';
112
+ const stack = componentStack ? `\n\nComponent stack:${componentStack}` : '';
113
+ const logCaughtError = () => logger.error(`[${scope}] error boundary captured an error in "${title}"${tile}, reference ${referenceId}${stack}`, normalized);
114
+ if (!onError) {
115
+ logCaughtError();
116
+ return;
117
+ }
118
+ try {
119
+ onError({ error: normalized, componentStack, referenceId, scope, title, tileRegistration });
120
+ }
121
+ catch (handlerError) {
122
+ logCaughtError();
123
+ logger.error(`[${scope}] error boundary onError handler threw`, handlerError);
124
+ }
125
+ }
126
+ render() {
127
+ var _b;
128
+ const { error, referenceId, capturedAt, componentStack } = this.state;
129
+ if (!error) {
130
+ return this.props.children;
131
+ }
132
+ const { scope, title, tileRegistration } = this.props;
133
+ const diagnostics = [
134
+ `Reference: ${referenceId}`,
135
+ `Scope: ${scope}`,
136
+ `Title: ${title}`,
137
+ `Tile registration: ${tileRegistration !== null && tileRegistration !== void 0 ? tileRegistration : 'N/A'}`,
138
+ `Captured at: ${capturedAt}`,
139
+ `URL: ${window.location.href}`,
140
+ `User agent: ${window.navigator.userAgent}`,
141
+ `Error name: ${error.name}`,
142
+ `Error message: ${error.message}`,
143
+ 'Error stack:',
144
+ (_b = error.stack) !== null && _b !== void 0 ? _b : 'No stack available',
145
+ 'Component stack:',
146
+ componentStack || 'No component stack available',
147
+ ].join('\n');
148
+ const isApp = scope === 'application';
149
+ return (_jsx(ErrorBoundaryFallback, { scope: scope, referenceId: referenceId, title: isApp ? 'Something went wrong' : `Something went wrong in "${title}"`, subtitle: isApp
150
+ ? 'The app hit an unexpected error. You can retry or copy the diagnostics.'
151
+ : 'This tile crashed, but the rest of the application keeps running. Retry or copy the diagnostics.', details: diagnostics, onRetry: this.handleRetry }));
152
+ }
153
+ }
154
+ _a = BOUNDARY_BRAND;
155
+ /**
156
+ * Top-level error boundary. Wrap the whole app in one instance; use {@link TileErrorBoundary}
157
+ * inside it.
158
+ *
159
+ * @remarks
160
+ * Renders a fallback with a reference ID, copyable diagnostics and a Retry button that remounts
161
+ * the children. Each caught error is passed to `onError`. Without one, it's logged through the
162
+ * {@link ERROR_BOUNDARY_LOGGER_NAME} logger instead, which a global log reporter receives. Pair with {@link onCaughtError} on the root so React doesn't log the
163
+ * error a second time.
164
+ *
165
+ * @example
166
+ * ```tsx
167
+ * createRoot(el, { onCaughtError }).render(
168
+ * <AppErrorBoundary onError={({ error, ...extra }) => Sentry.captureException(error, { extra })}>
169
+ * <App />
170
+ * </AppErrorBoundary>,
171
+ * );
172
+ * ```
173
+ * @public
174
+ */
175
+ export const AppErrorBoundary = ({ onError, children }) => (_jsx(BaseErrorBoundary, { scope: "application", title: "Application", onError: onError, children: children }));
176
+ /**
177
+ * Error boundary for a single tile, so a crash replaces only that tile with the fallback UI and
178
+ * the rest of the app keeps running.
179
+ * @public
180
+ */
181
+ export const TileErrorBoundary = ({ title, tileRegistration, onError, children, }) => (_jsx(BaseErrorBoundary, { scope: "tile", title: title, tileRegistration: tileRegistration, onError: onError, children: children }));
182
+ /**
183
+ * Wraps a component in a {@link TileErrorBoundary}.
184
+ *
185
+ * @param WrappedComponent - The tile component.
186
+ * @param title - Human-readable tile name shown in the fallback UI.
187
+ * @param tileRegistration - The tile's key in the layout.
188
+ * @param onError - Optional callback for caught errors.
189
+ * @public
190
+ */
191
+ export const withTileErrorBoundary = (WrappedComponent, title, tileRegistration, onError) => {
192
+ const WithTileErrorBoundary = (props) => (_jsx(TileErrorBoundary, { title: title, tileRegistration: tileRegistration, onError: onError, children: _jsx(WrappedComponent, Object.assign({}, props)) }));
193
+ WithTileErrorBoundary.displayName = `WithTileErrorBoundary(${title}:${tileRegistration}:${WrappedComponent.displayName || WrappedComponent.name || 'TileComponent'})`;
194
+ return WithTileErrorBoundary;
195
+ };
196
+ const isFoundationBoundary = (boundary) => typeof boundary === 'object' &&
197
+ boundary !== null &&
198
+ boundary[BOUNDARY_BRAND] === true;
199
+ /**
200
+ * Root `onCaughtError` for `createRoot`.
201
+ *
202
+ * @remarks
203
+ * By default React 19 logs every error a boundary catches to `console.error`, before the
204
+ * boundary's `componentDidCatch` runs. Console-capturing monitoring tools then report that bare
205
+ * error first, and can drop or duplicate the boundary's own report. {@link AppErrorBoundary} and
206
+ * {@link TileErrorBoundary} report or log errors themselves, so this skips them. Errors caught by
207
+ * any other boundary are still logged to `console.error`, with their component stack.
208
+ *
209
+ * @public
210
+ */
211
+ export const onCaughtError = (error, errorInfo) => {
212
+ var _b;
213
+ if (isFoundationBoundary(errorInfo.errorBoundary)) {
214
+ return;
215
+ }
216
+ console.error(error, '\n\nThe above error was caught by an error boundary.', (_b = errorInfo.componentStack) !== null && _b !== void 0 ? _b : '');
217
+ };
@@ -0,0 +1 @@
1
+ export { AppErrorBoundary, ERROR_BOUNDARY_LOGGER_NAME, onCaughtError, TileErrorBoundary, withTileErrorBoundary, } from './error-boundary';
package/dist/esm/index.js CHANGED
@@ -12,6 +12,8 @@
12
12
  * grid's `gridComponents` property).
13
13
  * - `reactFactory` / `reactFactoryWithProvider` — mount React component trees into
14
14
  * Genesis Foundation layout regions.
15
+ * - `AppErrorBoundary` / `TileErrorBoundary` / `withTileErrorBoundary` — error boundaries with a
16
+ * fallback UI and an `onError` reporting hook; pair with `onCaughtError` on the React root.
15
17
  *
16
18
  * Subpath entry points:
17
19
  * - `./flexlayout` — `flexlayout-react` at the version this package pins, re-exported with the
@@ -23,3 +25,4 @@ export { reactFactory, reactFactoryWithProvider } from './react-layout-factory';
23
25
  export { createReactRenderer } from './create-react-renderer';
24
26
  export { createGridProCellRenderer } from './create-grid-pro-cell-renderer';
25
27
  export { createGridProCellPortals } from './create-grid-pro-cell-portals';
28
+ export * from './error-boundary';