diff --git a/packages/dashboard/app/components/ActiveAgentsPanel.tsx b/packages/dashboard/app/components/ActiveAgentsPanel.tsx index 57441a1d5d..762b1741c4 100644 --- a/packages/dashboard/app/components/ActiveAgentsPanel.tsx +++ b/packages/dashboard/app/components/ActiveAgentsPanel.tsx @@ -27,9 +27,12 @@ function LiveAgentCard({ agent, projectId, onSelect, onOpenTaskLogs }: LiveAgent const cardRef = useRef(null); const [isInViewport, setIsInViewport] = useState(false); - // Gate the RuntimeFallbackBadge's polling to visible cards only, matching - // TaskCard.tsx's pattern -- without this, every live agent card (including - // ones scrolled off-screen) polls the runtime-fallback endpoint forever. + /* + FNXC:RuntimeFallback 2026-07-08-00:00: + Gate the RuntimeFallbackBadge's polling to visible cards only, matching + TaskCard.tsx's pattern -- without this, every live agent card (including + ones scrolled off-screen) polls the runtime-fallback endpoint forever. + */ useEffect(() => { if (typeof IntersectionObserver === "undefined") { setIsInViewport(true); diff --git a/packages/dashboard/app/components/AgentsView.tsx b/packages/dashboard/app/components/AgentsView.tsx index a98ccbe32b..7683d62bd1 100644 --- a/packages/dashboard/app/components/AgentsView.tsx +++ b/packages/dashboard/app/components/AgentsView.tsx @@ -327,15 +327,28 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin const agentRoles = getAgentRoles(t); const [showSystemAgents, setShowSystemAgents] = useState(false); - // Real IntersectionObserver-backed viewport gating for RuntimeFallbackBadge - // instances rendered per-card (board + list views), matching TaskCard.tsx's - // pattern. Cards are rendered inline inside a .map() rather than as their - // own components, so a single shared observer keyed by a per-card string - // (`board:{agentId}` / `list:{agentId}`, kept distinct so board and list - // never share visibility state for the same agent) replaces the per-card - // useRef/useState/useEffect triplet TaskCard uses for its single card root. + /* + FNXC:RuntimeFallback 2026-07-08-00:00: + Real IntersectionObserver-backed viewport gating for RuntimeFallbackBadge + instances rendered per-card (board + list views), matching TaskCard.tsx's + pattern. Cards are rendered inline inside a .map() rather than as their own + components, so a single shared observer keyed by a per-card string + (`board:{agentId}` / `list:{agentId}`, kept distinct so board and list never + share visibility state for the same agent) replaces the per-card + useRef/useState/useEffect triplet TaskCard uses for its single card root. + + registerAgentCardRef(key) is invoked inline in JSX on every render, so it MUST + return a cached, stable callback per key (agentCardRefCallbacksRef) rather than + a fresh closure. A fresh closure each render looks like an unmount+remount to + React; in environments without IntersectionObserver the mount callback calls + setVisibleAgentCardKeys, which re-renders, producing another fresh closure — an + infinite re-render loop (including jsdom). The cached entry is evicted when the + element truly unmounts (el === null) so the Map cannot grow without bound across + created/deleted agents. + */ const agentCardElsRef = useRef>(new Map()); const agentCardKeyByElRef = useRef>(new Map()); + const agentCardRefCallbacksRef = useRef void>>(new Map()); const [visibleAgentCardKeys, setVisibleAgentCardKeys] = useState>(new Set()); const agentCardObserverRef = useRef(null); @@ -374,31 +387,38 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin }; }, []); - const registerAgentCardRef = useCallback((key: string) => (el: HTMLDivElement | null) => { - const prevEl = agentCardElsRef.current.get(key); - if (prevEl) { - agentCardObserverRef.current?.unobserve(prevEl); - agentCardKeyByElRef.current.delete(prevEl); - } - if (el) { - agentCardElsRef.current.set(key, el); - agentCardKeyByElRef.current.set(el, key); - if (agentCardObserverRef.current) { - agentCardObserverRef.current.observe(el); - } else { - // No IntersectionObserver support: treat as always visible, same - // synchronous-true fallback TaskCard.tsx uses. - setVisibleAgentCardKeys((prev) => (prev.has(key) ? prev : new Set(prev).add(key))); + const registerAgentCardRef = useCallback((key: string) => { + const cached = agentCardRefCallbacksRef.current.get(key); + if (cached) return cached; + const callback = (el: HTMLDivElement | null) => { + const prevEl = agentCardElsRef.current.get(key); + if (prevEl) { + agentCardObserverRef.current?.unobserve(prevEl); + agentCardKeyByElRef.current.delete(prevEl); } - } else { - agentCardElsRef.current.delete(key); - setVisibleAgentCardKeys((prev) => { - if (!prev.has(key)) return prev; - const next = new Set(prev); - next.delete(key); - return next; - }); - } + if (el) { + agentCardElsRef.current.set(key, el); + agentCardKeyByElRef.current.set(el, key); + if (agentCardObserverRef.current) { + agentCardObserverRef.current.observe(el); + } else { + // No IntersectionObserver support: treat as always visible, same + // synchronous-true fallback TaskCard.tsx uses. + setVisibleAgentCardKeys((prev) => (prev.has(key) ? prev : new Set(prev).add(key))); + } + } else { + agentCardElsRef.current.delete(key); + agentCardRefCallbacksRef.current.delete(key); + setVisibleAgentCardKeys((prev) => { + if (!prev.has(key)) return prev; + const next = new Set(prev); + next.delete(key); + return next; + }); + } + }; + agentCardRefCallbacksRef.current.set(key, callback); + return callback; }, []); const isAgentCardInViewport = useCallback( diff --git a/packages/dashboard/app/hooks/useRuntimeFallbackStatus.ts b/packages/dashboard/app/hooks/useRuntimeFallbackStatus.ts index cf65dff81e..d389f52d51 100644 --- a/packages/dashboard/app/hooks/useRuntimeFallbackStatus.ts +++ b/packages/dashboard/app/hooks/useRuntimeFallbackStatus.ts @@ -66,10 +66,12 @@ function claimToastOnce(taskId: string, eventId: string): boolean { /** * Test-only escape hatch: clears the shared module-level dedupe store between - * test cases so one test's "already toasted" state cannot leak into the - * next. Not used by production code paths. + * test cases so one test's "already toasted" state cannot leak into the next. + * Guarded to a no-op outside the test build (import.meta.env.MODE) so it can + * never affect production code paths. */ export function __resetRuntimeFallbackToastDedupeStoreForTests(): void { + if (import.meta.env.MODE !== "test") return; toastedEventKeys.clear(); }