@genesislcap/foundation-react-utils 15.47.0 → 15.48.0-alpha-830388687.0

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,221 @@
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, followed by its
9
+ * reference ID, scope, title, tile registration and component stack, only when no `onError`
10
+ * 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 logCaughtError = () => logger.error(`[${scope}] error boundary captured an error`, normalized, {
112
+ referenceId,
113
+ scope,
114
+ title,
115
+ tileRegistration,
116
+ componentStack,
117
+ });
118
+ if (!onError) {
119
+ logCaughtError();
120
+ return;
121
+ }
122
+ try {
123
+ onError({ error: normalized, componentStack, referenceId, scope, title, tileRegistration });
124
+ }
125
+ catch (handlerError) {
126
+ logCaughtError();
127
+ logger.error(`[${scope}] error boundary onError handler threw`, handlerError);
128
+ }
129
+ }
130
+ render() {
131
+ var _b;
132
+ const { error, referenceId, capturedAt, componentStack } = this.state;
133
+ if (!error) {
134
+ return this.props.children;
135
+ }
136
+ const { scope, title, tileRegistration } = this.props;
137
+ const diagnostics = [
138
+ `Reference: ${referenceId}`,
139
+ `Scope: ${scope}`,
140
+ `Title: ${title}`,
141
+ `Tile registration: ${tileRegistration !== null && tileRegistration !== void 0 ? tileRegistration : 'N/A'}`,
142
+ `Captured at: ${capturedAt}`,
143
+ `URL: ${window.location.href}`,
144
+ `User agent: ${window.navigator.userAgent}`,
145
+ `Error name: ${error.name}`,
146
+ `Error message: ${error.message}`,
147
+ 'Error stack:',
148
+ (_b = error.stack) !== null && _b !== void 0 ? _b : 'No stack available',
149
+ 'Component stack:',
150
+ componentStack || 'No component stack available',
151
+ ].join('\n');
152
+ const isApp = scope === 'application';
153
+ return (_jsx(ErrorBoundaryFallback, { scope: scope, referenceId: referenceId, title: isApp ? 'Something went wrong' : `Something went wrong in "${title}"`, subtitle: isApp
154
+ ? 'The app hit an unexpected error. You can retry or copy the diagnostics.'
155
+ : 'This tile crashed, but the rest of the application keeps running. Retry or copy the diagnostics.', details: diagnostics, onRetry: this.handleRetry }));
156
+ }
157
+ }
158
+ _a = BOUNDARY_BRAND;
159
+ /**
160
+ * Top-level error boundary. Wrap the whole app in one instance; use {@link TileErrorBoundary}
161
+ * inside it.
162
+ *
163
+ * @remarks
164
+ * Renders a fallback with a reference ID, copyable diagnostics and a Retry button that remounts
165
+ * the children. Each caught error is passed to `onError`. Without one, it's logged through the
166
+ * {@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
167
+ * error a second time.
168
+ *
169
+ * @example
170
+ * ```tsx
171
+ * createRoot(el, { onCaughtError }).render(
172
+ * <AppErrorBoundary onError={({ error, ...extra }) => Sentry.captureException(error, { extra })}>
173
+ * <App />
174
+ * </AppErrorBoundary>,
175
+ * );
176
+ * ```
177
+ * @public
178
+ */
179
+ export const AppErrorBoundary = ({ onError, children }) => (_jsx(BaseErrorBoundary, { scope: "application", title: "Application", onError: onError, children: children }));
180
+ /**
181
+ * Error boundary for a single tile, so a crash replaces only that tile with the fallback UI and
182
+ * the rest of the app keeps running.
183
+ * @public
184
+ */
185
+ export const TileErrorBoundary = ({ title, tileRegistration, onError, children, }) => (_jsx(BaseErrorBoundary, { scope: "tile", title: title, tileRegistration: tileRegistration, onError: onError, children: children }));
186
+ /**
187
+ * Wraps a component in a {@link TileErrorBoundary}.
188
+ *
189
+ * @param WrappedComponent - The tile component.
190
+ * @param title - Human-readable tile name shown in the fallback UI.
191
+ * @param tileRegistration - The tile's key in the layout.
192
+ * @param onError - Optional callback for caught errors.
193
+ * @public
194
+ */
195
+ export const withTileErrorBoundary = (WrappedComponent, title, tileRegistration, onError) => {
196
+ const WithTileErrorBoundary = (props) => (_jsx(TileErrorBoundary, { title: title, tileRegistration: tileRegistration, onError: onError, children: _jsx(WrappedComponent, Object.assign({}, props)) }));
197
+ WithTileErrorBoundary.displayName = `WithTileErrorBoundary(${title}:${tileRegistration}:${WrappedComponent.displayName || WrappedComponent.name || 'TileComponent'})`;
198
+ return WithTileErrorBoundary;
199
+ };
200
+ const isFoundationBoundary = (boundary) => typeof boundary === 'object' &&
201
+ boundary !== null &&
202
+ boundary[BOUNDARY_BRAND] === true;
203
+ /**
204
+ * Root `onCaughtError` for `createRoot`.
205
+ *
206
+ * @remarks
207
+ * By default React 19 logs every error a boundary catches to `console.error`, before the
208
+ * boundary's `componentDidCatch` runs. Console-capturing monitoring tools then report that bare
209
+ * error first, and can drop or duplicate the boundary's own report. {@link AppErrorBoundary} and
210
+ * {@link TileErrorBoundary} report or log errors themselves, so this skips them. Errors caught by
211
+ * any other boundary are still logged to `console.error`, with their component stack.
212
+ *
213
+ * @public
214
+ */
215
+ export const onCaughtError = (error, errorInfo) => {
216
+ var _b;
217
+ if (isFoundationBoundary(errorInfo.errorBoundary)) {
218
+ return;
219
+ }
220
+ console.error(error, '\n\nThe above error was caught by an error boundary.', (_b = errorInfo.componentStack) !== null && _b !== void 0 ? _b : '');
221
+ };
@@ -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';