diff --git a/frontend/src/components/graph/NodeStatusIndicator.tsx b/frontend/src/components/graph/NodeStatusIndicator.tsx index 662621a..8516796 100644 --- a/frontend/src/components/graph/NodeStatusIndicator.tsx +++ b/frontend/src/components/graph/NodeStatusIndicator.tsx @@ -1,6 +1,7 @@ import { useId, useLayoutEffect, useRef, useState } from 'react' import type { ReactNode } from 'react' import { cn } from '@/lib/utils' +import { observeResize } from '@/lib/sharedResizeObserver' export type NodeStatus = 'loading' | 'success' | 'error' | 'initial' @@ -58,27 +59,17 @@ function BorderLoadingIndicator({ useLayoutEffect(() => { const container = containerRef.current if (!container) return - // Observe the first child (BaseNode) so we use its actual rendered size. const target = container.firstElementChild instanceof HTMLElement ? container.firstElementChild : container - const syncMeasure = () => { - const cw = (target as HTMLElement).offsetWidth - const ch = (target as HTMLElement).offsetHeight - if (cw > 0 && ch > 0) { - queueMicrotask(() => setMeasured({ w: cw, h: ch })) - } - } - syncMeasure() - const ro = new ResizeObserver((entries) => { - const entry = entries[0] - if (!entry) return - const { width: cw, height: ch } = entry.contentRect - setMeasured({ w: Math.round(cw), h: Math.round(ch) }) + const cw = (target as HTMLElement).offsetWidth + const ch = (target as HTMLElement).offsetHeight + if (cw > 0 && ch > 0) setMeasured({ w: cw, h: ch }) + const unObserve = observeResize(target, ({ width: w, height: h }) => { + if (w > 0 && h > 0) setMeasured({ w, h }) }) - ro.observe(target) - return () => ro.disconnect() + return unObserve }, []) useLayoutEffect(() => { diff --git a/frontend/src/hooks/useResizeHeight.ts b/frontend/src/hooks/useResizeHeight.ts index 4dc6242..03eede3 100644 --- a/frontend/src/hooks/useResizeHeight.ts +++ b/frontend/src/hooks/useResizeHeight.ts @@ -1,32 +1,30 @@ import { useEffect, useRef, useState } from 'react' +import { observeResize } from '@/lib/sharedResizeObserver' /** * Returns the height of the observed element, updating when it resizes (e.g. node resize). - * Used to give CodeMirror and other components an explicit height that tracks their container. + * Uses a shared ResizeObserver to reduce memory (one observer for all elements). * @param defaultHeight - Height used until the element is measured. * @param deps - Optional dependency array; when the ref is attached to a conditionally mounted element, pass deps (e.g. [viewMode]) so the effect re-runs when the element appears. */ export function useResizeHeight( - defaultHeight: number, - deps?: React.DependencyList + defaultHeight: number, + deps?: React.DependencyList ): [number, React.RefObject] { - const ref = useRef(null) - const [height, setHeight] = useState(defaultHeight) + const ref = useRef(null) + const [height, setHeight] = useState(defaultHeight) - useEffect(() => { - const el = ref.current - if (!el) return + useEffect(() => { + const el = ref.current + if (!el) return - const ro = new ResizeObserver((entries) => { - const entry = entries[0] - if (entry?.contentRect.height != null && entry.contentRect.height > 0) { - setHeight(entry.contentRect.height) - } - }) - ro.observe(el) - setHeight(el.getBoundingClientRect().height) - return () => ro.disconnect() - }, deps ?? []) + const unobserve = observeResize(el, (size) => { + if (size.height > 0) setHeight(size.height) + }) + const initial = el.getBoundingClientRect().height + if (initial > 0) setHeight(initial) + return unobserve + }, deps ?? []) - return [height, ref] + return [height, ref] } diff --git a/frontend/src/lib/sharedResizeObserver.ts b/frontend/src/lib/sharedResizeObserver.ts new file mode 100644 index 0000000..a86317f --- /dev/null +++ b/frontend/src/lib/sharedResizeObserver.ts @@ -0,0 +1,40 @@ +/** + * Single shared ResizeObserver to avoid one observer per element (memory and overhead). + * Callbacks are throttled to at most once per animation frame per element. + */ + +type Size = { width: number; height: number } + +const callbacks = new Map void>() +const rafScheduled = new Set() +let observer: ResizeObserver | null = null + +function getObserver(): ResizeObserver { + if (!observer) { + observer = new ResizeObserver((entries) => { + for (const entry of entries) { + const cb = callbacks.get(entry.target) + if (!cb || rafScheduled.has(entry.target)) continue + rafScheduled.add(entry.target) + const { width, height } = entry.contentRect + requestAnimationFrame(() => { + rafScheduled.delete(entry.target) + callbacks.get(entry.target)?.({ + width: Math.round(width), + height: Math.round(height), + }) + }) + } + }) + } + return observer +} + +export function observeResize(el: Element, callback: (size: Size) => void): () => void { + callbacks.set(el, callback) + getObserver().observe(el) + return () => { + callbacks.delete(el) + getObserver().unobserve(el) + } +}