mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 21:33:41 +02:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<HTMLSpanElement>(null);
|
||||
const [standalone, setStandalone] = useState(false);
|
||||
const [warm, setWarm] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const openTimerRef = useRef<ReturnType<typeof setTimeout> | 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 (
|
||||
<span
|
||||
ref={triggerRef}
|
||||
tabIndex={tabIndex}
|
||||
className={className}
|
||||
onPointerEnter={() => {
|
||||
setWarm(true);
|
||||
openTimerRef.current = setTimeout(
|
||||
() => setOpen(true),
|
||||
HOVER_CARD_OPEN_DELAY,
|
||||
);
|
||||
}}
|
||||
onFocus={() => {
|
||||
restoreFocusRef.current = true;
|
||||
setWarm(true);
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<HoverCard open={open} onOpenChange={setOpen}>
|
||||
<HoverCard>
|
||||
<HoverCardTrigger
|
||||
render={<span ref={triggerRef} />}
|
||||
tabIndex={tabIndex}
|
||||
className={className}
|
||||
onPointerLeave={clearOpenTimer}
|
||||
>
|
||||
{children}
|
||||
</HoverCardTrigger>
|
||||
|
||||
Reference in New Issue
Block a user