Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 48 additions & 2 deletions src/pages/SentryErrorBoundaryWrapper.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect } from 'react';
import { useEffect, useLayoutEffect, useRef } from 'react';
import type { RefObject } from 'react';
import { Outlet, useLocation } from 'react-router';
import * as Sentry from '@sentry/react';
import semver from 'semver';
Expand All @@ -8,10 +9,49 @@ import { useVersionQuery } from '@/core/react-query/init/queries';
import { getMinimumServerVersion, isDebug } from '@/core/util';
import useNavigateVoid from '@/hooks/useNavigateVoid';

const RECOVERY_THROTTLE_MS = 5000;

// Works around a documented React limitation, not specific to any React version: React doesn't support
// something outside its control (a browser extension, a third-party script) mutating a DOM node it manages —
// see https://react.dev/learn/manipulating-the-dom-with-refs ("Avoid changing DOM nodes managed by React") and
// https://github.com/facebook/react/issues/17256 (still open; a Chrome extension causes this exact error) — a
// later commit's removeChild/insertBefore then targets a node that's already gone. It isn't caused by our code —
// seen on unrelated screens with frequent re-renders, e.g. /webui/firstrun/start-server (status polling) and
// /webui/collection/filter/live (debounced search) — so we recover instead of showing the crash page.
const isRecoverableDomRaceError = (error: unknown): error is Error =>
error instanceof Error
&& error.name === 'NotFoundError'
&& /Failed to execute '(removeChild|insertBefore)' on 'Node'/.test(error.message);

type RaceRecoveryFallbackProps = {
error?: Error;
lastAutoRecoveredAtRef: RefObject<number>;
resetError: () => void;
};

const RaceRecoveryFallback = ({ error, lastAutoRecoveredAtRef, resetError }: RaceRecoveryFallbackProps) => {
const recoveryRef = lastAutoRecoveredAtRef;
const now = Date.now();
const canAutoRecover = isRecoverableDomRaceError(error)
&& (now - recoveryRef.current > RECOVERY_THROTTLE_MS);

useLayoutEffect(() => {
if (!canAutoRecover) return;
recoveryRef.current = now;
resetError();
// oxlint-disable-next-line react-hooks/exhaustive-deps -- only re-run when the recoverability verdict changes
}, [canAutoRecover, resetError]);

if (canAutoRecover) return null;

return <ErrorBoundary error={error} resetError={resetError} />;
};

const SentryErrorBoundaryWrapper = () => {
const { pathname } = useLocation();
const navigate = useNavigateVoid();
const versionQuery = useVersionQuery();
const lastAutoRecoveredAtRef = useRef(0);

useEffect(() => {
Sentry.setTag('server_release', versionQuery.data?.Server?.Version ?? 'Unknown');
Expand All @@ -34,7 +74,13 @@ const SentryErrorBoundaryWrapper = () => {
return (
<Sentry.ErrorBoundary
// oxlint-disable-next-line typescript/unbound-method -- Not our code, so we cannot fix it
fallback={({ error, resetError }) => <ErrorBoundary error={error as Error} resetError={resetError} />}
fallback={({ error, resetError }) => (
<RaceRecoveryFallback
error={error as Error}
lastAutoRecoveredAtRef={lastAutoRecoveredAtRef}
resetError={resetError}
/>
)}
>
<Outlet />
</Sentry.ErrorBoundary>
Expand Down