fix(FUX-039): harden runtime-fallback agent-card viewport gating

Follow-up hardening on the RuntimeFallbackBadge viewport-gating work:

- AgentsView.tsx: registerAgentCardRef now returns a cached, stable callback per
  key (agentCardRefCallbacksRef) instead of a fresh closure each render. A fresh
  closure reads as unmount+remount to React; in environments without
  IntersectionObserver the mount path calls setVisibleAgentCardKeys -> re-render
  -> another fresh closure -> an infinite re-render loop (including jsdom). The
  cached entry is evicted on true unmount (el === null) so the Map cannot grow
  unbounded across created/deleted agents.
- ActiveAgentsPanel.tsx: document the viewport-gated badge polling with an FNXC
  comment (behavior unchanged).
- useRuntimeFallbackStatus.ts: guard __resetRuntimeFallbackToastDedupeStoreForTests
  to a no-op outside the test build (import.meta.env.MODE !== "test") so the
  test-only dedupe reset can never affect production code paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-08 15:24:00 -07:00
parent 1cb3cde667
commit 0039480334
3 changed files with 61 additions and 36 deletions

View File

@@ -27,9 +27,12 @@ function LiveAgentCard({ agent, projectId, onSelect, onOpenTaskLogs }: LiveAgent
const cardRef = useRef<HTMLDivElement>(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);

View File

@@ -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<Map<string, Element>>(new Map());
const agentCardKeyByElRef = useRef<Map<Element, string>>(new Map());
const agentCardRefCallbacksRef = useRef<Map<string, (el: HTMLDivElement | null) => void>>(new Map());
const [visibleAgentCardKeys, setVisibleAgentCardKeys] = useState<Set<string>>(new Set());
const agentCardObserverRef = useRef<IntersectionObserver | null>(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(

View File

@@ -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();
}