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.
This commit is contained in:
Josh Hawkins 2026-03-05 06:12:24 -06:00
parent 4e37035d87
commit 93889b221c
2 changed files with 31 additions and 16 deletions

View File

@ -1,4 +1,4 @@
import { MutableRefObject, useEffect, useMemo, useState } from "react";
import { MutableRefObject, useEffect, useMemo, useRef, useState } from "react";
type RefType = MutableRefObject<Element | null> | 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;

View File

@ -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,