Skip to content

Commit ca96c37

Browse files
author
Sébastien Henau
committed
add component stack frames + tests
1 parent 3b2250b commit ca96c37

File tree

9 files changed

+310
-15
lines changed

9 files changed

+310
-15
lines changed

.claude/docs/projects - react-improvements.md

Lines changed: 100 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,17 @@
1111
- [x] FlareErrorBoundary `onReset` passes previous error
1212
- [x] FlareErrorBoundary supports `resetKeys` property
1313
- [x] Add `flareReactErrorHandler`
14+
- [x] Structured component stack parsing with sourcemap-ready frames
1415

1516
## FlareErrorBoundary: `fallback` property
1617

1718
### Inspiration
1819

19-
- [Sentry ErrorBoundary `fallback`](https://docs.sentry.io/platforms/javascript/guides/react/features/error-boundary/#fallback-ui-options) -- supports a static element and a render function receiving `{ error, componentStack, resetError }`
20-
- [react-error-boundary](https://github.com/bvaughn/react-error-boundary) -- the most popular standalone error boundary library, supports `fallback`, `fallbackRender`, and `FallbackComponent` as three separate props
20+
- [Sentry ErrorBoundary
21+
`fallback`](https://docs.sentry.io/platforms/javascript/guides/react/features/error-boundary/#fallback-ui-options) --
22+
supports a static element and a render function receiving `{ error, componentStack, resetError }`
23+
- [react-error-boundary](https://github.com/bvaughn/react-error-boundary) -- the most popular standalone error boundary
24+
library, supports `fallback`, `fallbackRender`, and `FallbackComponent` as three separate props
2125

2226
### Why
2327

@@ -55,9 +59,11 @@ The `fallback` prop accepts either a static `ReactNode` or a render function. Th
5559

5660
### Inspiration
5761

58-
- [Sentry ErrorBoundary `onError`](https://docs.sentry.io/platforms/javascript/guides/react/features/error-boundary/#options-reference) -- called when the boundary encounters an error
59-
- [react-error-boundary `onError`](https://github.com/bvaughn/react-error-boundary?tab=readme-ov-file#onerror) -- same concept, receives `(error, info)`
60-
62+
- [Sentry ErrorBoundary
63+
`onError`](https://docs.sentry.io/platforms/javascript/guides/react/features/error-boundary/#options-reference) --
64+
called when the boundary encounters an error
65+
- [react-error-boundary `onError`](https://github.com/bvaughn/react-error-boundary?tab=readme-ov-file#onerror) -- same
66+
concept, receives `(error, info)`
6167

6268
### Why
6369

@@ -79,7 +85,9 @@ showing a toast, updating app state, etc. This fires *after* the error has been
7985

8086
### Inspiration
8187

82-
- [Sentry ErrorBoundary `beforeCapture`](https://docs.sentry.io/platforms/javascript/guides/react/features/error-boundary/#options-reference) -- the only competitor that offers this; receives the Sentry scope to set tags and context before the event is sent
88+
- [Sentry ErrorBoundary
89+
`beforeCapture`](https://docs.sentry.io/platforms/javascript/guides/react/features/error-boundary/#options-reference) --
90+
the only competitor that offers this; receives the Sentry scope to set tags and context before the event is sent
8391

8492
No other competitor (Datadog, Bugsnag, Rollbar, LogRocket) provides an equivalent hook.
8593

@@ -104,8 +112,11 @@ the developer decide what to include rather than trying to automatically seriali
104112

105113
### Inspiration
106114

107-
- [react-error-boundary `onReset`](https://github.com/bvaughn/react-error-boundary?tab=readme-ov-file#onreset) -- the primary inspiration; called when the boundary resets, receives details about what triggered the reset
108-
- [Sentry ErrorBoundary `onReset`](https://github.com/getsentry/sentry-javascript/blob/master/packages/react/src/errorboundary.tsx) -- exists in Sentry's source code (receives `error, componentStack, eventId`) but is not documented in their official docs
115+
- [react-error-boundary `onReset`](https://github.com/bvaughn/react-error-boundary?tab=readme-ov-file#onreset) -- the
116+
primary inspiration; called when the boundary resets, receives details about what triggered the reset
117+
- [Sentry ErrorBoundary
118+
`onReset`](https://github.com/getsentry/sentry-javascript/blob/master/packages/react/src/errorboundary.tsx) -- exists
119+
in Sentry's source code (receives `error, componentStack, eventId`) but is not documented in their official docs
109120

110121
### Why
111122

@@ -131,7 +142,8 @@ error allows conditional cleanup based on what went wrong.
131142

132143
### Inspiration
133144

134-
- [react-error-boundary `resetKeys`](https://github.com/bvaughn/react-error-boundary?tab=readme-ov-file#resetkeys) -- the sole source for this pattern; Sentry does not offer it
145+
- [react-error-boundary `resetKeys`](https://github.com/bvaughn/react-error-boundary?tab=readme-ov-file#resetkeys) --
146+
the sole source for this pattern; Sentry does not offer it
135147

136148
This is a feature unique to react-error-boundary that neither Sentry nor any other error tracking competitor provides.
137149
When any value in the `resetKeys` array changes between renders (compared via `Object.is`), the boundary automatically
@@ -165,8 +177,11 @@ function App() {
165177

166178
### Inspiration
167179

168-
- [Sentry `reactErrorHandler`](https://docs.sentry.io/platforms/javascript/guides/react/features/error-boundary/#error-hooks-vs-errorboundary) -- provides `Sentry.reactErrorHandler()` for all three React 19 root error hooks, with an optional callback parameter
169-
- [React 19 `createRoot` error handling docs](https://react.dev/reference/react-dom/client/createRoot#parameters) -- the React docs describing `onCaughtError`, `onUncaughtError`, and `onRecoverableError`
180+
- [Sentry
181+
`reactErrorHandler`](https://docs.sentry.io/platforms/javascript/guides/react/features/error-boundary/#error-hooks-vs-errorboundary) --
182+
provides `Sentry.reactErrorHandler()` for all three React 19 root error hooks, with an optional callback parameter
183+
- [React 19 `createRoot` error handling docs](https://react.dev/reference/react-dom/client/createRoot#parameters) -- the
184+
React docs describing `onCaughtError`, `onUncaughtError`, and `onRecoverableError`
170185

171186
Our API mirrors Sentry's approach: a wrapper function that accepts an optional callback. The key difference is that
172187
`flareReactErrorHandler` also handles non-Error values (strings, objects) by converting them to proper Error instances
@@ -198,4 +213,77 @@ const root = createRoot(document.getElementById('root')!, {
198213
});
199214

200215
root.render(<App />);
201-
```
216+
```
217+
218+
## Structured component stack parsing with sourcemap-ready frames
219+
220+
### Inspiration
221+
222+
- [Sentry
223+
`captureReactException`](https://github.com/getsentry/sentry-javascript/blob/master/packages/react/src/error.ts) --
224+
creates a synthetic Error with the raw `componentStack` as its `.stack` property and links it to the original error
225+
via `error.cause`; server-side sourcemap processing then applies to this synthetic stack trace
226+
- [Bugsnag `formatComponentStack`](https://github.com/bugsnag/bugsnag-js/tree/main/packages/plugin-react) -- only trims
227+
whitespace; sends componentStack as raw metadata; has
228+
an [open feature request (issue #2097)](https://github.com/bugsnag/bugsnag-js/issues/2097) to apply sourcemaps to it
229+
- Rollbar and LogRocket do not parse or sourcemap the componentStack at all
230+
231+
### Why
232+
233+
React's `ErrorInfo.componentStack` is a raw multiline string whose format differs between browser engines:
234+
235+
- **Chromium** (Chrome, Edge, Opera, Brave): `at ComponentName (http://localhost:5173/src/App.tsx:12:9)`
236+
- **Firefox/Safari** (Gecko, WebKit): `ComponentName@http://localhost:5173/src/App.tsx:12:9`
237+
238+
Previously, `formatComponentStack()` just split this string by newlines into a `string[]`, giving the Flare dashboard
239+
nothing structured to work with. By parsing each line into `{ component, file, line, column }` objects, we give the
240+
backend clean structured data for sourcemap resolution and rich dashboard rendering (clickable source links, component
241+
tree views, searchable component names) -- without requiring the backend to re-parse a raw string or a synthetic stack
242+
trace like Sentry does.
243+
244+
Both `componentStack` (original `string[]`) and `componentStackFrames` (new `ComponentStackFrame[]`) are sent in the
245+
report context for backwards compatibility. The backend can adopt the structured format when ready.
246+
247+
### Report context structure
248+
249+
```json
250+
{
251+
"context": {
252+
"react": {
253+
"componentStack": [
254+
"at ErrorComponent (http://localhost:5173/src/App.tsx:12:9)",
255+
"at div",
256+
"at App (http://localhost:5173/src/App.tsx:5:3)"
257+
],
258+
"componentStackFrames": [
259+
{
260+
"component": "ErrorComponent",
261+
"file": "http://localhost:5173/src/App.tsx",
262+
"line": 12,
263+
"column": 9
264+
},
265+
{
266+
"component": "div",
267+
"file": null,
268+
"line": null,
269+
"column": null
270+
},
271+
{
272+
"component": "App",
273+
"file": "http://localhost:5173/src/App.tsx",
274+
"line": 5,
275+
"column": 3
276+
}
277+
]
278+
}
279+
}
280+
}
281+
```
282+
283+
### Backend requirements
284+
285+
The Flare backend/dashboard needs to:
286+
287+
1. Read `componentStackFrames` from the report context
288+
2. Apply sourcemap resolution to each frame's `file`/`line`/`column`
289+
3. Render the component stack as a structured list in the dashboard

packages/react/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,16 @@
2828
"scripts": {
2929
"prepublishOnly": "npm run build",
3030
"build": "tsdown src/index.ts --format cjs,esm --dts --clean",
31+
"test": "vitest run",
3132
"typescript": "tsc --noEmit"
3233
},
3334
"devDependencies": {
3435
"@flareapp/js": "file:../js",
3536
"@types/react": "^19.0.0",
3637
"react": "^19.0.0",
3738
"tsdown": "^0.20.3",
38-
"typescript": "^5.7.0"
39+
"typescript": "^5.7.0",
40+
"vitest": "^3.2.1"
3941
},
4042
"peerDependencies": {
4143
"@flareapp/js": "^1.0.0",

packages/react/src/FlareErrorBoundary.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { flare } from '@flareapp/js';
22
import { Component, ErrorInfo, type PropsWithChildren, type ReactNode } from 'react';
33

44
import { formatComponentStack } from './format-component-stack';
5+
import { parseComponentStack } from './parse-component-stack';
56
import { FlareReactContext } from './types';
67

78
export type FlareErrorBoundaryFallbackProps = {
@@ -36,9 +37,12 @@ export class FlareErrorBoundary extends Component<FlareErrorBoundaryProps, Flare
3637
errorInfo,
3738
});
3839

40+
const rawStack = errorInfo.componentStack ?? '';
41+
3942
const context: FlareReactContext = {
4043
react: {
41-
componentStack: formatComponentStack(errorInfo.componentStack ?? ''),
44+
componentStack: formatComponentStack(rawStack),
45+
componentStackFrames: parseComponentStack(rawStack),
4246
},
4347
};
4448

packages/react/src/constants.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// Chrome:
2+
// "at ComponentName (http://localhost:5173/src/App.tsx:12:9)"
3+
// (no source): "at div"
4+
export const CHROMIUM_STACK_REGEX = /^at\s+(\S+)(?:\s+\((.+):(\d+):(\d+)\))?$/;
5+
6+
// Firefox/Safari:
7+
// "ComponentName@http://localhost:5173/src/App.tsx:12:9"
8+
// (no source): "div"
9+
export const FIREFOX_SAFARI_STACK_REGEX = /^(\S+?)@(.+):(\d+):(\d+)$/;

packages/react/src/flare-react-error-handler.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { flare } from '@flareapp/js';
22

33
import { convertToError } from './convert-to-error';
44
import { formatComponentStack } from './format-component-stack';
5+
import { parseComponentStack } from './parse-component-stack';
56
import { FlareReactContext } from './types';
67

78
export type FlareReactErrorHandlerCallback = (error: unknown, errorInfo: { componentStack?: string }) => void;
@@ -10,9 +11,12 @@ export function flareReactErrorHandler(callback?: FlareReactErrorHandlerCallback
1011
return (error: unknown, errorInfo: { componentStack?: string }) => {
1112
const errorObject = convertToError(error);
1213

14+
const rawStack = errorInfo.componentStack ?? '';
15+
1316
const context: FlareReactContext = {
1417
react: {
15-
componentStack: formatComponentStack(errorInfo.componentStack ?? ''),
18+
componentStack: formatComponentStack(rawStack),
19+
componentStackFrames: parseComponentStack(rawStack),
1620
},
1721
};
1822

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { CHROMIUM_STACK_REGEX, FIREFOX_SAFARI_STACK_REGEX } from './constants';
2+
import { ComponentStackFrame } from './types';
3+
4+
export function parseComponentStack(stack: string): ComponentStackFrame[] {
5+
return stack
6+
.split(/\s*\n\s*/g)
7+
.filter((line) => line.length > 0)
8+
.map((line): ComponentStackFrame => {
9+
const chromeMatch = line.match(CHROMIUM_STACK_REGEX);
10+
11+
if (chromeMatch) {
12+
return {
13+
component: chromeMatch[1],
14+
file: chromeMatch[2] ?? null,
15+
line: chromeMatch[3] ? Number(chromeMatch[3]) : null,
16+
column: chromeMatch[4] ? Number(chromeMatch[4]) : null,
17+
};
18+
}
19+
20+
const firefoxSafariMatch = line.match(FIREFOX_SAFARI_STACK_REGEX);
21+
22+
if (firefoxSafariMatch) {
23+
return {
24+
component: firefoxSafariMatch[1],
25+
file: firefoxSafariMatch[2],
26+
line: Number(firefoxSafariMatch[3]),
27+
column: Number(firefoxSafariMatch[4]),
28+
};
29+
}
30+
31+
// For unrecognized formats, we'll strip the leading "at " if it is present
32+
const component = line.replace(/^at\s+/, '');
33+
34+
return {
35+
component,
36+
file: null,
37+
line: null,
38+
column: null,
39+
};
40+
});
41+
}

packages/react/src/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
1+
export type ComponentStackFrame = {
2+
component: string;
3+
file: string | null;
4+
line: number | null;
5+
column: number | null;
6+
};
7+
18
export type FlareReactContext = {
29
react: {
310
componentStack: string[];
11+
componentStackFrames: ComponentStackFrame[];
412
};
513
};
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { describe, expect, test } from 'vitest';
2+
3+
import { formatComponentStack } from '../src/format-component-stack';
4+
5+
describe('formatComponentStack', () => {
6+
test('splits a component stack into lines', () => {
7+
const stack = `
8+
at ErrorComponent (http://localhost:5173/src/App.tsx:12:9)
9+
at div
10+
at App (http://localhost:5173/src/App.tsx:5:3)
11+
`;
12+
13+
expect(formatComponentStack(stack)).toEqual([
14+
'at ErrorComponent (http://localhost:5173/src/App.tsx:12:9)',
15+
'at div',
16+
'at App (http://localhost:5173/src/App.tsx:5:3)',
17+
]);
18+
});
19+
20+
test('returns empty array for empty string', () => {
21+
expect(formatComponentStack('')).toEqual([]);
22+
});
23+
24+
test('returns empty array for whitespace-only string', () => {
25+
expect(formatComponentStack(' \n \n ')).toEqual([]);
26+
});
27+
28+
test('trims whitespace around newlines but not at string edges', () => {
29+
const stack = ' at Foo \n at Bar ';
30+
31+
expect(formatComponentStack(stack)).toEqual([' at Foo', 'at Bar ']);
32+
});
33+
34+
test('filters out empty lines between components', () => {
35+
const stack = 'at Foo\n\n\nat Bar';
36+
37+
expect(formatComponentStack(stack)).toEqual(['at Foo', 'at Bar']);
38+
});
39+
40+
test('handles a single-line stack', () => {
41+
expect(formatComponentStack('at App')).toEqual(['at App']);
42+
});
43+
});

0 commit comments

Comments
 (0)