-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
feat(solidjs): Add withSentryErrorBoundary
HOC
#12421
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6595452
942406b
322c45f
d187fe5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
import * as Sentry from '@sentry/solidjs'; | ||
import { ErrorBoundary } from 'solid-js'; | ||
|
||
const SentryErrorBoundary = Sentry.withSentryErrorBoundary(ErrorBoundary); | ||
|
||
export default function ErrorBoundaryExample() { | ||
return ( | ||
<SentryErrorBoundary | ||
fallback={(error, reset) => ( | ||
<section class="bg-gray-100 text-gray-700 p-8"> | ||
<h1 class="text-2xl font-bold">Error Boundary Fallback</h1> | ||
<div class="flex items-center space-x-2 mb-4"> | ||
<code>{error.message}</code> | ||
</div> | ||
<button id="errorBoundaryResetBtn" class="border rounded-lg px-2 border-gray-900" onClick={reset}> | ||
Reset | ||
</button> | ||
</section> | ||
)} | ||
> | ||
<NonExistentComponent /> | ||
</SentryErrorBoundary> | ||
); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,75 @@ | ||
import { expect, test } from '@playwright/test'; | ||
import { waitForError } from '@sentry-internal/test-utils'; | ||
|
||
test('captures an exception', async ({ page }) => { | ||
const errorEventPromise = waitForError('solidjs', errorEvent => { | ||
return !errorEvent.type; | ||
}); | ||
|
||
const [, errorEvent] = await Promise.all([page.goto('/error-boundary-example'), errorEventPromise]); | ||
|
||
expect(errorEvent).toMatchObject({ | ||
exception: { | ||
values: [ | ||
{ | ||
type: 'ReferenceError', | ||
value: 'NonExistentComponent is not defined', | ||
mechanism: { | ||
type: 'generic', | ||
handled: true, | ||
}, | ||
}, | ||
], | ||
}, | ||
transaction: '/error-boundary-example', | ||
}); | ||
}); | ||
|
||
test('captures a second exception after resetting the boundary', async ({ page }) => { | ||
const firstErrorEventPromise = waitForError('solidjs', errorEvent => { | ||
return !errorEvent.type; | ||
}); | ||
|
||
const [, firstErrorEvent] = await Promise.all([page.goto('/error-boundary-example'), firstErrorEventPromise]); | ||
|
||
expect(firstErrorEvent).toMatchObject({ | ||
exception: { | ||
values: [ | ||
{ | ||
type: 'ReferenceError', | ||
value: 'NonExistentComponent is not defined', | ||
mechanism: { | ||
type: 'generic', | ||
handled: true, | ||
}, | ||
}, | ||
], | ||
}, | ||
transaction: '/error-boundary-example', | ||
}); | ||
|
||
const secondErrorEventPromise = waitForError('solidjs', errorEvent => { | ||
return !errorEvent.type; | ||
}); | ||
|
||
const [, secondErrorEvent] = await Promise.all([ | ||
page.locator('#errorBoundaryResetBtn').click(), | ||
await secondErrorEventPromise, | ||
]); | ||
|
||
expect(secondErrorEvent).toMatchObject({ | ||
exception: { | ||
values: [ | ||
{ | ||
type: 'ReferenceError', | ||
value: 'NonExistentComponent is not defined', | ||
mechanism: { | ||
type: 'generic', | ||
handled: true, | ||
}, | ||
}, | ||
], | ||
}, | ||
transaction: '/error-boundary-example', | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,31 @@ | ||
import { captureException } from '@sentry/browser'; | ||
import type { Component, JSX } from 'solid-js'; | ||
import { mergeProps, splitProps } from 'solid-js'; | ||
import { createComponent } from 'solid-js/web'; | ||
|
||
type ErrorBoundaryProps = { | ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
fallback: JSX.Element | ((err: any, reset: () => void) => JSX.Element); | ||
children: JSX.Element; | ||
}; | ||
|
||
/** | ||
* A higher-order component to wrap Solid's ErrorBoundary to capture exceptions. | ||
*/ | ||
export function withSentryErrorBoundary(ErrorBoundary: Component<ErrorBoundaryProps>): Component<ErrorBoundaryProps> { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I have a naive question (sorry if I missed convos around this): Why do users need to pass in the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not naive at all. That's the approach I initially took, but it doesn't quite do the job correctly. From local testing and similar experience with the router instrumentation, certain functions and functionality are app context specific. For the router, this meant that we couldn't use Hope this helps to understand it a bit better. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Makes sense, thanks for explaining! |
||
const SentryErrorBoundary = (props: ErrorBoundaryProps): JSX.Element => { | ||
const [local, others] = splitProps(props, ['fallback']); | ||
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
const fallback = (error: any, reset: () => void): JSX.Element => { | ||
captureException(error); | ||
|
||
const f = local.fallback; | ||
return typeof f === 'function' ? f(error, reset) : f; | ||
}; | ||
|
||
return createComponent(ErrorBoundary, mergeProps({ fallback }, others)); | ||
}; | ||
|
||
return SentryErrorBoundary; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,108 @@ | ||
/* eslint-disable @typescript-eslint/unbound-method */ | ||
import type * as SentryBrowser from '@sentry/browser'; | ||
import { createTransport, getCurrentScope, setCurrentClient } from '@sentry/core'; | ||
import { render } from '@solidjs/testing-library'; | ||
import userEvent from '@testing-library/user-event'; | ||
import { vi } from 'vitest'; | ||
|
||
import { ErrorBoundary } from 'solid-js'; | ||
import { BrowserClient, withSentryErrorBoundary } from '../src'; | ||
|
||
const mockCaptureException = vi.fn(); | ||
vi.mock('@sentry/browser', async () => { | ||
const actual = await vi.importActual<typeof SentryBrowser>('@sentry/browser'); | ||
return { | ||
...actual, | ||
captureException: (...args) => mockCaptureException(...args), | ||
} as typeof SentryBrowser; | ||
}); | ||
|
||
const user = userEvent.setup(); | ||
const SentryErrorBoundary = withSentryErrorBoundary(ErrorBoundary); | ||
|
||
describe('withSentryErrorBoundary', () => { | ||
function createMockBrowserClient(): BrowserClient { | ||
return new BrowserClient({ | ||
integrations: [], | ||
tracesSampleRate: 1, | ||
transport: () => createTransport({ recordDroppedEvent: () => undefined }, _ => Promise.resolve({})), | ||
stackParser: () => [], | ||
}); | ||
} | ||
|
||
beforeEach(() => { | ||
vi.clearAllMocks(); | ||
|
||
const client = createMockBrowserClient(); | ||
setCurrentClient(client); | ||
}); | ||
|
||
afterEach(() => { | ||
getCurrentScope().setClient(undefined); | ||
}); | ||
|
||
it('calls `captureException` when an error occurs`', () => { | ||
render(() => ( | ||
<SentryErrorBoundary fallback={<div>Ooops, an error occurred.</div>}> | ||
<NonExistentComponent /> | ||
</SentryErrorBoundary> | ||
)); | ||
|
||
expect(mockCaptureException).toHaveBeenCalledTimes(1); | ||
expect(mockCaptureException).toHaveBeenLastCalledWith(new ReferenceError('NonExistentComponent is not defined')); | ||
}); | ||
|
||
it('renders the fallback component', async () => { | ||
const { findByText } = render(() => ( | ||
<SentryErrorBoundary fallback={<div>Ooops, an error occurred.</div>}> | ||
<NonExistentComponent /> | ||
</SentryErrorBoundary> | ||
)); | ||
|
||
expect(await findByText('Ooops, an error occurred.')).toBeInTheDocument(); | ||
}); | ||
|
||
it('passes the `error` and `reset` function to the fallback component', () => { | ||
const mockFallback = vi.fn(); | ||
|
||
render(() => { | ||
<SentryErrorBoundary fallback={mockFallback}> | ||
<NonExistentComponent /> | ||
</SentryErrorBoundary>; | ||
}); | ||
|
||
expect(mockFallback).toHaveBeenCalledTimes(1); | ||
expect(mockFallback).toHaveBeenCalledWith( | ||
new ReferenceError('NonExistentComponent is not defined'), | ||
expect.any(Function), | ||
); | ||
}); | ||
|
||
it('calls `captureException` again after resetting', async () => { | ||
const { findByRole } = render(() => ( | ||
<SentryErrorBoundary fallback={(_, reset) => <button onClick={reset}>Reset</button>}> | ||
<NonExistentComponent /> | ||
</SentryErrorBoundary> | ||
)); | ||
|
||
expect(mockCaptureException).toHaveBeenCalledTimes(1); | ||
expect(mockCaptureException).toHaveBeenNthCalledWith(1, new ReferenceError('NonExistentComponent is not defined')); | ||
|
||
const button = await findByRole('button'); | ||
await user.click(button); | ||
|
||
expect(mockCaptureException).toHaveBeenCalledTimes(2); | ||
expect(mockCaptureException).toHaveBeenNthCalledWith(2, new ReferenceError('NonExistentComponent is not defined')); | ||
}); | ||
|
||
it('renders children when there is no error', async () => { | ||
const { queryByText } = render(() => ( | ||
<SentryErrorBoundary fallback={<div>Oops, an error occurred.</div>}> | ||
<div>Adopt a cat</div> | ||
</SentryErrorBoundary> | ||
)); | ||
|
||
expect(await queryByText('Adopt a cat')).toBeInTheDocument(); | ||
expect(await queryByText('Ooops, an error occurred')).not.toBeInTheDocument(); | ||
}); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is
solid-js/web
always available to us? I don't see it being listed as peerDependency or dependency in the SDK's package.jsonThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I believe it's available through
solid-js
. At least I didn't have any issues developing locally. The solidjs templates (these are used to start projects alacreate-react-app
) also don't specifically list it as dep, e.g. here.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ahh I see, thx for explaining! Let's leave it as is and if we get error reports (I have a feeling pnpm could complain 👀 ) we can add it later.