diff --git a/src/layouts/shared/ErrorBoundary/ErrorBoundary.tsx b/src/layouts/shared/ErrorBoundary/ErrorBoundary.tsx index c84df81842..32c866d114 100644 --- a/src/layouts/shared/ErrorBoundary/ErrorBoundary.tsx +++ b/src/layouts/shared/ErrorBoundary/ErrorBoundary.tsx @@ -5,6 +5,8 @@ import { Fragment, ReactElement, ReactNode } from 'react' import A from 'ui/A' +import { ErrorBoundaryWithRecovery } from './ErrorBoundaryWithRecovery' + function DefaultUI() { return (
@@ -33,14 +35,16 @@ export default function ErrorBoundary({ children, }: ErrorBoundaryProps) { return ( - - sentryScopes.forEach(([key, value]) => scope.setTag(key, value)) - } - fallback={errorComponent} - > - {children} - + + + sentryScopes.forEach(([key, value]) => scope.setTag(key, value)) + } + fallback={errorComponent} + > + {children} + + ) } diff --git a/src/layouts/shared/ErrorBoundary/ErrorBoundaryWithRecovery.tsx b/src/layouts/shared/ErrorBoundary/ErrorBoundaryWithRecovery.tsx new file mode 100644 index 0000000000..5411bd43ff --- /dev/null +++ b/src/layouts/shared/ErrorBoundary/ErrorBoundaryWithRecovery.tsx @@ -0,0 +1,49 @@ +import { Component, Fragment, type ReactNode } from 'react' + +const MAX_RETRIES = 2 + +/** + * Detects DOM NotFoundErrors caused by browser extensions directly mutating + * React-managed nodes (e.g. via insertBefore / removeChild). These errors are + * non-actionable from the application side. + */ +function isBrowserExtensionDOMError(error: unknown): boolean { + if (!(error instanceof Error)) return false + if (error.name !== 'NotFoundError') return false + const msg = error.message ?? '' + return msg.includes('insertBefore') || msg.includes('removeChild') +} + +interface Props { + children: ReactNode +} + +interface State { + retryKey: number +} + +/** + * Wraps children so that when a browser-extension-caused DOM manipulation error + * is caught, the subtree is automatically remounted (up to MAX_RETRIES times) + * rather than surfacing an error fallback to the user. + */ +export class ErrorBoundaryWithRecovery extends Component { + state: State = { retryKey: 0 } + + componentDidCatch(error: Error) { + if ( + isBrowserExtensionDOMError(error) && + this.state.retryKey < MAX_RETRIES + ) { + // Incrementing the key forces React to unmount and remount the children, + // resetting the DOM to a clean state. + this.setState((prev) => ({ retryKey: prev.retryKey + 1 })) + } + } + + render() { + return ( + {this.props.children} + ) + } +} diff --git a/src/sentry.ts b/src/sentry.ts index 2c53256335..e54d5d50b6 100644 --- a/src/sentry.ts +++ b/src/sentry.ts @@ -161,11 +161,22 @@ export const setupSentry = ({ replaysOnErrorSampleRate: config?.SENTRY_ERROR_SAMPLE_RATE, // profiling sample rate profilesSampleRate: config?.SENTRY_PROFILING_SAMPLE_RATE, - beforeSend: (event, _hint) => { + beforeSend: (event, hint) => { if (checkForBlockedUserAgents()) { return null } + // Drop DOM NotFoundErrors caused by browser extensions directly + // manipulating React-managed DOM nodes (e.g. insertBefore/removeChild). + // These are non-actionable third-party interference errors. + const error = hint?.originalException + if (error instanceof Error && error.name === 'NotFoundError') { + const msg = error.message ?? '' + if (msg.includes('insertBefore') || msg.includes('removeChild')) { + return null + } + } + return event }, beforeSendTransaction: (event, _hint) => {