Skip to content

Commit 5561f0a

Browse files
andreiborzabillyvg
authored andcommitted
feat(solidjs): Add withSentryErrorBoundary HOC (#12421)
To automatically capture exceptions from inside a component tree and render a fallback component, wrap the native Solid JS `ErrorBoundary` component with `Sentry.withSentryErrorBoundary`. ```js import * as Sentry from '@sentry/solidjs'; import { ErrorBoundary } from 'solid-js'; Sentry.init({ dsn: '__PUBLIC_DSN__', tracesSampleRate: 1.0, // Capture 100% of the transactions }); const SentryErrorBoundary = Sentry.withSentryErrorBoundary(ErrorBoundary); render( () => ( <SentryErrorBoundary fallback={err => <div>Error: {err.message}</div>}> <ProblematicComponent /> </SentryErrorBoundary> ), document.getElementById('root'), ); ``` **Note**: When using an `ErrorBoundary` in conjunction with Solid Router, the fallback component renders twice, see [here](solidjs/solid-router#440).
1 parent 569eda6 commit 5561f0a

File tree

11 files changed

+326
-2
lines changed

11 files changed

+326
-2
lines changed

dev-packages/e2e-tests/test-applications/solidjs/src/pageroot.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ export default function PageRoot(props) {
1010
Home
1111
</A>
1212
</li>
13+
<li>
14+
<A href="/error-boundary-example" class="no-underline hover:underline">
15+
Error Boundary Example
16+
</A>
17+
</li>
1318
<li class="py-2 px-4">
1419
<A href="/error" class="no-underline hover:underline">
1520
Error
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import * as Sentry from '@sentry/solidjs';
2+
import { ErrorBoundary } from 'solid-js';
3+
4+
const SentryErrorBoundary = Sentry.withSentryErrorBoundary(ErrorBoundary);
5+
6+
export default function ErrorBoundaryExample() {
7+
return (
8+
<SentryErrorBoundary
9+
fallback={(error, reset) => (
10+
<section class="bg-gray-100 text-gray-700 p-8">
11+
<h1 class="text-2xl font-bold">Error Boundary Fallback</h1>
12+
<div class="flex items-center space-x-2 mb-4">
13+
<code>{error.message}</code>
14+
</div>
15+
<button id="errorBoundaryResetBtn" class="border rounded-lg px-2 border-gray-900" onClick={reset}>
16+
Reset
17+
</button>
18+
</section>
19+
)}
20+
>
21+
<NonExistentComponent />
22+
</SentryErrorBoundary>
23+
);
24+
}

dev-packages/e2e-tests/test-applications/solidjs/src/routes.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { lazy } from 'solid-js';
22

3+
import ErrorBoundaryExample from './pages/errorboundaryexample';
34
import Home from './pages/home';
45

56
export const routes = [
@@ -11,6 +12,10 @@ export const routes = [
1112
path: '/user/:id',
1213
component: lazy(() => import('./pages/user')),
1314
},
15+
{
16+
path: '/error-boundary-example',
17+
component: ErrorBoundaryExample,
18+
},
1419
{
1520
path: '**',
1621
component: lazy(() => import('./errors/404')),
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForError } from '@sentry-internal/test-utils';
3+
4+
test('captures an exception', async ({ page }) => {
5+
const errorEventPromise = waitForError('solidjs', errorEvent => {
6+
return !errorEvent.type;
7+
});
8+
9+
const [, errorEvent] = await Promise.all([page.goto('/error-boundary-example'), errorEventPromise]);
10+
11+
expect(errorEvent).toMatchObject({
12+
exception: {
13+
values: [
14+
{
15+
type: 'ReferenceError',
16+
value: 'NonExistentComponent is not defined',
17+
mechanism: {
18+
type: 'generic',
19+
handled: true,
20+
},
21+
},
22+
],
23+
},
24+
transaction: '/error-boundary-example',
25+
});
26+
});
27+
28+
test('captures a second exception after resetting the boundary', async ({ page }) => {
29+
const firstErrorEventPromise = waitForError('solidjs', errorEvent => {
30+
return !errorEvent.type;
31+
});
32+
33+
const [, firstErrorEvent] = await Promise.all([page.goto('/error-boundary-example'), firstErrorEventPromise]);
34+
35+
expect(firstErrorEvent).toMatchObject({
36+
exception: {
37+
values: [
38+
{
39+
type: 'ReferenceError',
40+
value: 'NonExistentComponent is not defined',
41+
mechanism: {
42+
type: 'generic',
43+
handled: true,
44+
},
45+
},
46+
],
47+
},
48+
transaction: '/error-boundary-example',
49+
});
50+
51+
const secondErrorEventPromise = waitForError('solidjs', errorEvent => {
52+
return !errorEvent.type;
53+
});
54+
55+
const [, secondErrorEvent] = await Promise.all([
56+
page.locator('#errorBoundaryResetBtn').click(),
57+
await secondErrorEventPromise,
58+
]);
59+
60+
expect(secondErrorEvent).toMatchObject({
61+
exception: {
62+
values: [
63+
{
64+
type: 'ReferenceError',
65+
value: 'NonExistentComponent is not defined',
66+
mechanism: {
67+
type: 'generic',
68+
handled: true,
69+
},
70+
},
71+
],
72+
},
73+
transaction: '/error-boundary-example',
74+
});
75+
});

packages/solidjs/README.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@ Sentry.init({
3737
dsn: '__PUBLIC_DSN__',
3838
integrations: [Sentry.solidRouterBrowserTracingIntegration({ useBeforeLeave, useLocation })],
3939
tracesSampleRate: 1.0, // Capture 100% of the transactions
40-
debug: true,
4140
});
4241

4342
const SentryRouter = Sentry.withSentryRouterRouting(Router);
@@ -53,6 +52,32 @@ render(
5352
);
5453
```
5554

55+
# ErrorBoundary
56+
57+
To automatically capture exceptions from inside a component tree and render a fallback component, wrap the native Solid
58+
JS `ErrorBoundary` component with `Sentry.withSentryErrorBoundary`.
59+
60+
```js
61+
import * as Sentry from '@sentry/solidjs';
62+
import { ErrorBoundary } from 'solid-js';
63+
64+
Sentry.init({
65+
dsn: '__PUBLIC_DSN__',
66+
tracesSampleRate: 1.0, // Capture 100% of the transactions
67+
});
68+
69+
const SentryErrorBoundary = Sentry.withSentryErrorBoundary(ErrorBoundary);
70+
71+
render(
72+
() => (
73+
<SentryErrorBoundary fallback={err => <div>Error: {err.message}</div>}>
74+
<ProblematicComponent />
75+
</SentryErrorBoundary>
76+
),
77+
document.getElementById('root'),
78+
);
79+
```
80+
5681
# Sourcemaps and Releases
5782

5883
To generate and upload source maps of your Solid JS app bundle, check our guide

packages/solidjs/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@
5454
"@solidjs/router": "^0.13.5",
5555
"@solidjs/testing-library": "0.8.5",
5656
"solid-js": "^1.8.11",
57+
"@testing-library/jest-dom": "^6.4.5",
58+
"@testing-library/user-event": "^14.5.2",
5759
"vite-plugin-solid": "^2.8.2"
5860
},
5961
"scripts": {

packages/solidjs/src/errorboundary.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { captureException } from '@sentry/browser';
2+
import type { Component, JSX } from 'solid-js';
3+
import { mergeProps, splitProps } from 'solid-js';
4+
import { createComponent } from 'solid-js/web';
5+
6+
type ErrorBoundaryProps = {
7+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
8+
fallback: JSX.Element | ((err: any, reset: () => void) => JSX.Element);
9+
children: JSX.Element;
10+
};
11+
12+
/**
13+
* A higher-order component to wrap Solid's ErrorBoundary to capture exceptions.
14+
*/
15+
export function withSentryErrorBoundary(ErrorBoundary: Component<ErrorBoundaryProps>): Component<ErrorBoundaryProps> {
16+
const SentryErrorBoundary = (props: ErrorBoundaryProps): JSX.Element => {
17+
const [local, others] = splitProps(props, ['fallback']);
18+
19+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
20+
const fallback = (error: any, reset: () => void): JSX.Element => {
21+
captureException(error);
22+
23+
const f = local.fallback;
24+
return typeof f === 'function' ? f(error, reset) : f;
25+
};
26+
27+
return createComponent(ErrorBoundary, mergeProps({ fallback }, others));
28+
};
29+
30+
return SentryErrorBoundary;
31+
}

packages/solidjs/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export * from '@sentry/browser';
33
export { init } from './sdk';
44

55
export * from './solidrouter';
6+
export * from './errorboundary';
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/* eslint-disable @typescript-eslint/unbound-method */
2+
import type * as SentryBrowser from '@sentry/browser';
3+
import { createTransport, getCurrentScope, setCurrentClient } from '@sentry/core';
4+
import { render } from '@solidjs/testing-library';
5+
import userEvent from '@testing-library/user-event';
6+
import { vi } from 'vitest';
7+
8+
import { ErrorBoundary } from 'solid-js';
9+
import { BrowserClient, withSentryErrorBoundary } from '../src';
10+
11+
const mockCaptureException = vi.fn();
12+
vi.mock('@sentry/browser', async () => {
13+
const actual = await vi.importActual<typeof SentryBrowser>('@sentry/browser');
14+
return {
15+
...actual,
16+
captureException: (...args) => mockCaptureException(...args),
17+
} as typeof SentryBrowser;
18+
});
19+
20+
const user = userEvent.setup();
21+
const SentryErrorBoundary = withSentryErrorBoundary(ErrorBoundary);
22+
23+
describe('withSentryErrorBoundary', () => {
24+
function createMockBrowserClient(): BrowserClient {
25+
return new BrowserClient({
26+
integrations: [],
27+
tracesSampleRate: 1,
28+
transport: () => createTransport({ recordDroppedEvent: () => undefined }, _ => Promise.resolve({})),
29+
stackParser: () => [],
30+
});
31+
}
32+
33+
beforeEach(() => {
34+
vi.clearAllMocks();
35+
36+
const client = createMockBrowserClient();
37+
setCurrentClient(client);
38+
});
39+
40+
afterEach(() => {
41+
getCurrentScope().setClient(undefined);
42+
});
43+
44+
it('calls `captureException` when an error occurs`', () => {
45+
render(() => (
46+
<SentryErrorBoundary fallback={<div>Ooops, an error occurred.</div>}>
47+
<NonExistentComponent />
48+
</SentryErrorBoundary>
49+
));
50+
51+
expect(mockCaptureException).toHaveBeenCalledTimes(1);
52+
expect(mockCaptureException).toHaveBeenLastCalledWith(new ReferenceError('NonExistentComponent is not defined'));
53+
});
54+
55+
it('renders the fallback component', async () => {
56+
const { findByText } = render(() => (
57+
<SentryErrorBoundary fallback={<div>Ooops, an error occurred.</div>}>
58+
<NonExistentComponent />
59+
</SentryErrorBoundary>
60+
));
61+
62+
expect(await findByText('Ooops, an error occurred.')).toBeInTheDocument();
63+
});
64+
65+
it('passes the `error` and `reset` function to the fallback component', () => {
66+
const mockFallback = vi.fn();
67+
68+
render(() => {
69+
<SentryErrorBoundary fallback={mockFallback}>
70+
<NonExistentComponent />
71+
</SentryErrorBoundary>;
72+
});
73+
74+
expect(mockFallback).toHaveBeenCalledTimes(1);
75+
expect(mockFallback).toHaveBeenCalledWith(
76+
new ReferenceError('NonExistentComponent is not defined'),
77+
expect.any(Function),
78+
);
79+
});
80+
81+
it('calls `captureException` again after resetting', async () => {
82+
const { findByRole } = render(() => (
83+
<SentryErrorBoundary fallback={(_, reset) => <button onClick={reset}>Reset</button>}>
84+
<NonExistentComponent />
85+
</SentryErrorBoundary>
86+
));
87+
88+
expect(mockCaptureException).toHaveBeenCalledTimes(1);
89+
expect(mockCaptureException).toHaveBeenNthCalledWith(1, new ReferenceError('NonExistentComponent is not defined'));
90+
91+
const button = await findByRole('button');
92+
await user.click(button);
93+
94+
expect(mockCaptureException).toHaveBeenCalledTimes(2);
95+
expect(mockCaptureException).toHaveBeenNthCalledWith(2, new ReferenceError('NonExistentComponent is not defined'));
96+
});
97+
98+
it('renders children when there is no error', async () => {
99+
const { queryByText } = render(() => (
100+
<SentryErrorBoundary fallback={<div>Oops, an error occurred.</div>}>
101+
<div>Adopt a cat</div>
102+
</SentryErrorBoundary>
103+
));
104+
105+
expect(await queryByText('Adopt a cat')).toBeInTheDocument();
106+
expect(await queryByText('Ooops, an error occurred')).not.toBeInTheDocument();
107+
});
108+
});

packages/solidjs/tsconfig.test.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55

66
"compilerOptions": {
77
// should include all types from `./tsconfig.json` plus types for all test frameworks used
8-
"types": ["vitest/globals"]
8+
"types": ["vitest/globals", "vite/client", "@testing-library/jest-dom"],
99

1010
// other package-specific, test-specific options
11+
"jsx": "preserve",
12+
"jsxImportSource": "solid-js"
1113
}
1214
}

0 commit comments

Comments
 (0)