From 0098088eba132af686890c7d8f37b58e390f64ca Mon Sep 17 00:00:00 2001 From: hidden4003 Date: Sun, 2 Aug 2026 18:11:15 +0300 Subject: [PATCH] Auto-recover from the known React DOM removeChild/insertBefore race instead of showing the crash page --- src/pages/SentryErrorBoundaryWrapper.tsx | 50 +++++++++++++++++++++++- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/src/pages/SentryErrorBoundaryWrapper.tsx b/src/pages/SentryErrorBoundaryWrapper.tsx index 27ff2b22d..ffe1096ed 100644 --- a/src/pages/SentryErrorBoundaryWrapper.tsx +++ b/src/pages/SentryErrorBoundaryWrapper.tsx @@ -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'; @@ -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; + 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 ; +}; + 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'); @@ -34,7 +74,13 @@ const SentryErrorBoundaryWrapper = () => { return ( } + fallback={({ error, resetError }) => ( + + )} >