From 5927bfc635b06d8594364278e7d58e2cb1a140e0 Mon Sep 17 00:00:00 2001 From: hidden4003 Date: Sun, 2 Aug 2026 12:17:11 +0300 Subject: [PATCH] Auto-recover from the known React 19 removeChild/insertBefore DOM race instead of showing the crash page --- src/pages/SentryErrorBoundaryWrapper.tsx | 47 +++++++++++++++++++++++- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/src/pages/SentryErrorBoundaryWrapper.tsx b/src/pages/SentryErrorBoundaryWrapper.tsx index 27ff2b22d..bffbd7443 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,46 @@ 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 still-open React 19 DOM reconciliation race (https://github.com/facebook/react/issues/14740) +// where a commit's removeChild/insertBefore 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 +71,13 @@ const SentryErrorBoundaryWrapper = () => { return ( } + fallback={({ error, resetError }) => ( + + )} >