From f66af071d6d507572daecff528996d2cc62572c5 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 5 Mar 2026 06:12:24 -0600 Subject: [PATCH] fix React 19 infinite re-render loop on live dashboard The "Maximum update depth exceeded" error was caused by two issues: 1. useDeferredStreamMetadata returned a new `{}` default on every render when SWR data was undefined, creating an unstable reference that triggered the useEffect in useCameraLiveMode on every render cycle. Fixed by using a stable module-level EMPTY_METADATA constant. 2. useResizeObserver's rest parameter `...refs` created a new array on every render, causing its useEffect to re-run and re-observe elements continuously. Fixed by stabilizing refs with useRef and only reconnecting the observer when actual DOM elements change. --- web/src/hooks/resize-observer.ts | 27 +++++++++---------- web/src/hooks/use-deferred-stream-metadata.ts | 3 ++- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/web/src/hooks/resize-observer.ts b/web/src/hooks/resize-observer.ts index 1e174af7e..f5a3e1ec6 100644 --- a/web/src/hooks/resize-observer.ts +++ b/web/src/hooks/resize-observer.ts @@ -31,25 +31,24 @@ export function useResizeObserver(...refs: RefType[]) { [], ); + // Resolve refs to actual DOM elements for use as stable effect dependencies. + // Rest params create a new array each call, but the underlying elements are + // stable DOM nodes, so spreading them into the dep array avoids re-running + // the effect on every render. + const elements = refs.map((ref) => + ref instanceof Window ? document.body : ref.current, + ); + useEffect(() => { - refs.forEach((ref) => { - if (ref instanceof Window) { - resizeObserver.observe(document.body); - } else if (ref.current) { - resizeObserver.observe(ref.current); - } + elements.forEach((el) => { + if (el) resizeObserver.observe(el); }); return () => { - refs.forEach((ref) => { - if (ref instanceof Window) { - resizeObserver.unobserve(document.body); - } else if (ref.current) { - resizeObserver.unobserve(ref.current); - } - }); + resizeObserver.disconnect(); }; - }, [refs, resizeObserver]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [...elements, resizeObserver]); if (dimensions.length == refs.length) { return dimensions; diff --git a/web/src/hooks/use-deferred-stream-metadata.ts b/web/src/hooks/use-deferred-stream-metadata.ts index 8e68b6a6a..44bd1bb0c 100644 --- a/web/src/hooks/use-deferred-stream-metadata.ts +++ b/web/src/hooks/use-deferred-stream-metadata.ts @@ -5,6 +5,7 @@ import { LiveStreamMetadata } from "@/types/live"; const FETCH_TIMEOUT_MS = 10000; const DEFER_DELAY_MS = 2000; +const EMPTY_METADATA: { [key: string]: LiveStreamMetadata } = {}; /** * Hook that fetches go2rtc stream metadata with deferred loading. @@ -77,7 +78,7 @@ export default function useDeferredStreamMetadata(streamNames: string[]) { return metadata; }, []); - const { data: metadata = {} } = useSWR<{ + const { data: metadata = EMPTY_METADATA } = useSWR<{ [key: string]: LiveStreamMetadata; }>(swrKey, fetcher, { revalidateOnFocus: false,