From 93889b221cab825fa22103918f0310abe8a116fd 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 | 44 ++++++++++++------- web/src/hooks/use-deferred-stream-metadata.ts | 3 +- 2 files changed, 31 insertions(+), 16 deletions(-) diff --git a/web/src/hooks/resize-observer.ts b/web/src/hooks/resize-observer.ts index 1e174af7e..a4c52e4f2 100644 --- a/web/src/hooks/resize-observer.ts +++ b/web/src/hooks/resize-observer.ts @@ -1,4 +1,4 @@ -import { MutableRefObject, useEffect, useMemo, useState } from "react"; +import { MutableRefObject, useEffect, useMemo, useRef, useState } from "react"; type RefType = MutableRefObject | Window; @@ -31,25 +31,39 @@ export function useResizeObserver(...refs: RefType[]) { [], ); + // Stabilize refs array to prevent useEffect from re-running on every render + // (rest params create a new array each call) + const stableRefs = useRef(refs); + stableRefs.current = refs; + + // Track observed elements to detect actual changes + const observedElements = useRef<(Element | null)[]>([]); + useEffect(() => { - refs.forEach((ref) => { - if (ref instanceof Window) { - resizeObserver.observe(document.body); - } else if (ref.current) { - resizeObserver.observe(ref.current); - } + const currentRefs = stableRefs.current; + const elements = currentRefs.map((ref) => + ref instanceof Window ? document.body : ref.current, + ); + + // Only re-observe if actual DOM elements changed + const prevElements = observedElements.current; + const changed = + elements.length !== prevElements.length || + elements.some((el, i) => el !== prevElements[i]); + + if (!changed) return; + + // Disconnect all and re-observe + resizeObserver.disconnect(); + elements.forEach((el) => { + if (el) resizeObserver.observe(el); }); + observedElements.current = elements; return () => { - refs.forEach((ref) => { - if (ref instanceof Window) { - resizeObserver.unobserve(document.body); - } else if (ref.current) { - resizeObserver.unobserve(ref.current); - } - }); + resizeObserver.disconnect(); }; - }, [refs, 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,