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:
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user