From e8ada0e89cb328f34a52df3a9fca1cc8cebff62b Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Thu, 16 Jul 2026 09:29:27 +0800 Subject: [PATCH] fix(views): stop avatar hover cards opening on brushed-past avatars (MUL-4827) (#5497) ActorAvatarHoverCardShell deferred mounting the Base UI PreviewCard root until the first pointerenter, then emulated the missing first-dwell open with a manual 600ms timer. Base UI drives hover through native mouseenter/mouseleave listeners on the trigger and installs its close path inside the mouseleave handler, so swapping the node mid-gesture loses both the event that cancels a pending open and the one that closes the popup. Flicking the pointer across a dense list armed one timer per avatar and cancelled none: in real Chromium a single sweep left 5 profile cards open with the pointer nowhere near them, and no hover-close path remained. The same cold span also opened on touch taps (Base UI sets mouseOnly) and opened instantly on focus, with no focus-visible gate and no delay. Drop the cold/warm swap, the manual timer, the manual focus-open and the focus restore, and let Base UI drive hover and focus natively. Appearance, tabIndex and the standalone-ancestor probe are unchanged. Mounting the root eagerly costs ~0.13ms of JS per avatar in real Chromium (+19.5ms at 150 avatars, the widest realistic board) and adds zero DOM while closed -- the popup subtree and its queries stay unmounted until open. Virtualization already caps mounted rows at 30 (10 per board column), and the popup-deferral work that motivated the hack is untouched. Verified: tsc --noEmit, eslint, and @multica/views vitest (212 files / 2102 tests) pass. A real-Chromium harness passes 7/7 hover/touch/keyboard scenarios on this fix and reproduces 3 failures on the pre-fix code. Still owed: real-device Electron trace on the widest/densest board. Co-authored-by: Claude Opus 4.8 --- packages/views/common/actor-avatar.tsx | 71 +++++--------------------- 1 file changed, 12 insertions(+), 59 deletions(-) diff --git a/packages/views/common/actor-avatar.tsx b/packages/views/common/actor-avatar.tsx index 3681ae2dd..70e38e8a4 100644 --- a/packages/views/common/actor-avatar.tsx +++ b/packages/views/common/actor-avatar.tsx @@ -270,22 +270,21 @@ function SquadAvatarHoverCard({ ); } -// Matches Base UI PreviewCard's OPEN_DELAY, so the emulated first-dwell open -// below feels identical to the native hover-open. -const HOVER_CARD_OPEN_DELAY = 600; - // Common chrome shared between agent and member hover cards. Keeps focus // behaviour and width consistent so the two surfaces feel structurally // parallel — content varies, frame doesn't. // -// The HoverCard (Base UI PreviewCard root + trigger) mounts lazily on first -// pointerenter/focus: dense surfaces render hundreds of these avatars and the -// per-instance popup machinery was a measurable slice of surface remount cost. -// Cold phase renders the same span with identical classes, so the swap is -// invisible. Because the pointer is already inside the trigger when the real -// HoverCard mounts, Base UI's own hover-open never fires for that first dwell -// — a manual timer with the same delay emulates it; afterwards Base UI drives -// the controlled `open` via `onOpenChange`. +// Do NOT defer-mount the HoverCard on pointerenter to save per-avatar mount +// cost (MUL-4827). Base UI drives hover through native mouseenter/mouseleave +// listeners on the trigger element, and installs its close path *inside* the +// mouseleave handler — so a trigger that never received a real mouseenter can +// neither cancel a pending open nor ever hover-close. Warming on pointerenter +// swaps the node mid-gesture and loses exactly those events, which made +// brushed-past avatars pop open ~600ms later and stick. This is the same +// invariant DeferredPopup documents: deferral is only sound for events that +// END a gesture (click/Enter), and hover starts one. Mounting the root eagerly +// costs ~0.15ms of JS per avatar and adds zero DOM while closed (the popup +// subtree, and its queries, stay unmounted until open). function ActorAvatarHoverCardShell({ content, children, @@ -295,10 +294,6 @@ function ActorAvatarHoverCardShell({ }) { const triggerRef = useRef(null); const [standalone, setStandalone] = useState(false); - const [warm, setWarm] = useState(false); - const [open, setOpen] = useState(false); - const openTimerRef = useRef | null>(null); - const restoreFocusRef = useRef(false); useEffect(() => { const el = triggerRef.current; @@ -307,59 +302,17 @@ function ActorAvatarHoverCardShell({ setStandalone(!ancestor); }, []); - // A focus-triggered swap unmounts the focused cold span; give focus back to - // the real trigger so Escape/blur keep working. - useEffect(() => { - if (warm && restoreFocusRef.current) { - restoreFocusRef.current = false; - triggerRef.current?.focus(); - } - }, [warm]); - - const clearOpenTimer = () => { - if (openTimerRef.current !== null) { - clearTimeout(openTimerRef.current); - openTimerRef.current = null; - } - }; - useEffect(() => clearOpenTimer, []); - const tabIndex = standalone ? 0 : -1; const className = standalone ? "inline-flex cursor-pointer rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" : "inline-flex cursor-pointer"; - if (!warm) { - return ( - { - setWarm(true); - openTimerRef.current = setTimeout( - () => setOpen(true), - HOVER_CARD_OPEN_DELAY, - ); - }} - onFocus={() => { - restoreFocusRef.current = true; - setWarm(true); - setOpen(true); - }} - > - {children} - - ); - } - return ( - + } tabIndex={tabIndex} className={className} - onPointerLeave={clearOpenTimer} > {children}