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 cardRef = useRef<HTMLDivElement>(null);
|
||||||
const [isInViewport, setIsInViewport] = useState(false);
|
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
|
FNXC:RuntimeFallback 2026-07-08-00:00:
|
||||||
// ones scrolled off-screen) polls the runtime-fallback endpoint forever.
|
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(() => {
|
useEffect(() => {
|
||||||
if (typeof IntersectionObserver === "undefined") {
|
if (typeof IntersectionObserver === "undefined") {
|
||||||
setIsInViewport(true);
|
setIsInViewport(true);
|
||||||
|
|||||||
@@ -327,15 +327,28 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
|||||||
const agentRoles = getAgentRoles(t);
|
const agentRoles = getAgentRoles(t);
|
||||||
const [showSystemAgents, setShowSystemAgents] = useState(false);
|
const [showSystemAgents, setShowSystemAgents] = useState(false);
|
||||||
|
|
||||||
// Real IntersectionObserver-backed viewport gating for RuntimeFallbackBadge
|
/*
|
||||||
// instances rendered per-card (board + list views), matching TaskCard.tsx's
|
FNXC:RuntimeFallback 2026-07-08-00:00:
|
||||||
// pattern. Cards are rendered inline inside a .map() rather than as their
|
Real IntersectionObserver-backed viewport gating for RuntimeFallbackBadge
|
||||||
// own components, so a single shared observer keyed by a per-card string
|
instances rendered per-card (board + list views), matching TaskCard.tsx's
|
||||||
// (`board:{agentId}` / `list:{agentId}`, kept distinct so board and list
|
pattern. Cards are rendered inline inside a .map() rather than as their own
|
||||||
// never share visibility state for the same agent) replaces the per-card
|
components, so a single shared observer keyed by a per-card string
|
||||||
// useRef/useState/useEffect triplet TaskCard uses for its single card root.
|
(`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 agentCardElsRef = useRef<Map<string, Element>>(new Map());
|
||||||
const agentCardKeyByElRef = useRef<Map<Element, string>>(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 [visibleAgentCardKeys, setVisibleAgentCardKeys] = useState<Set<string>>(new Set());
|
||||||
const agentCardObserverRef = useRef<IntersectionObserver | null>(null);
|
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 registerAgentCardRef = useCallback((key: string) => {
|
||||||
const prevEl = agentCardElsRef.current.get(key);
|
const cached = agentCardRefCallbacksRef.current.get(key);
|
||||||
if (prevEl) {
|
if (cached) return cached;
|
||||||
agentCardObserverRef.current?.unobserve(prevEl);
|
const callback = (el: HTMLDivElement | null) => {
|
||||||
agentCardKeyByElRef.current.delete(prevEl);
|
const prevEl = agentCardElsRef.current.get(key);
|
||||||
}
|
if (prevEl) {
|
||||||
if (el) {
|
agentCardObserverRef.current?.unobserve(prevEl);
|
||||||
agentCardElsRef.current.set(key, el);
|
agentCardKeyByElRef.current.delete(prevEl);
|
||||||
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 {
|
if (el) {
|
||||||
agentCardElsRef.current.delete(key);
|
agentCardElsRef.current.set(key, el);
|
||||||
setVisibleAgentCardKeys((prev) => {
|
agentCardKeyByElRef.current.set(el, key);
|
||||||
if (!prev.has(key)) return prev;
|
if (agentCardObserverRef.current) {
|
||||||
const next = new Set(prev);
|
agentCardObserverRef.current.observe(el);
|
||||||
next.delete(key);
|
} else {
|
||||||
return next;
|
// 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(
|
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-only escape hatch: clears the shared module-level dedupe store between
|
||||||
* test cases so one test's "already toasted" state cannot leak into the
|
* test cases so one test's "already toasted" state cannot leak into the next.
|
||||||
* next. Not used by production code paths.
|
* 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 {
|
export function __resetRuntimeFallbackToastDedupeStoreForTests(): void {
|
||||||
|
if (import.meta.env.MODE !== "test") return;
|
||||||
toastedEventKeys.clear();
|
toastedEventKeys.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user