fix(core): stop popup triggers from fighting their own light dismiss (#5004) - #5018
fix(core): stop popup triggers from fighting their own light dismiss (#5004)#5018cixzhang wants to merge 4 commits into
Conversation
…5004) A browser light dismiss and the trigger's own click come from one press: the popover is dismissed on pointerup and the click follows a beat later. Which one React sees first is a race that varies by engine and by load, and losing it means the click reads a popup that is already closed and reopens it — the button appears not to close the menu at all. Two components carried their own 50ms timing guard against this; the rest carried nothing. The layer primitive now answers the question directly: wasJustDismissed() compares the gesture that dismissed the layer with the gesture in flight, so a click from that same press is absorbed no matter how long the main thread was blocked in between, while a deliberate second press is always a new gesture. Both hand-rolled copies collapse into it. The same light dismiss also fires for controls that live ON the trigger — the clear and status buttons — which made them unusable while the popup they belong to was open. keepOpenProps names such a control an invoker of the popover for the duration of the press, which is what stops the dismissal; the attribute comes off afterwards so the button does not report itself as expanded to assistive tech.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
PR Analysis Report📚 Storybook PreviewView Storybook for this PR 🧪 Sandbox PreviewView Sandbox for this PR Modified ComponentsComplexSelector (@astryxdesign/core) · View in Storybook
DropdownMenu (@astryxdesign/core) · View in Storybook
Field (@astryxdesign/core) · View in Storybook
Layer (@astryxdesign/core) · View in Storybook
MultiSelector (@astryxdesign/core) · View in Storybook
Popover (@astryxdesign/core) · View in Storybook
Selector (@astryxdesign/core) · View in Storybook
Bundle Size Summary
Accessibility AuditStatus: No accessibility violations detected. Generated by PR Enrichment workflow | Storybook | Sandbox | View full report |
|
Thanks — the gesture counter is the right primitive, and having one place that knows a dismissal just happened is a good direction. Needs a merge with main; #4769 landed today and this will conflict. Three things: The guard only reaches callers that go through
[Reviewed by Robohands and Cindy] |
The guard only reached callers routing through toggle(); show()/hide() callers had to opt in. Guarding in show() holds it for every caller, so ComplexSelector's private 50ms timer goes with it. The dismissal is also one-shot now: it is spent by the click that ends the press it came from, so a later synthesized click — AT activation — is not read as part of a gesture that ended long ago.
|
Merged The guard moved into The dismissal is one-shot. It is spent by the click that ends the press it came from — a one-shot bubble-phase One more defect the guard exposed. Real ChromiumPlaywright, trusted presses,
Also in Chromium, with the fix: third press re-opens everywhere; a synthesized Tests
Against the shared dismissal stackMerging this branch into #4881 auto-merges Not done
[Pushed by Robohands] |
`keepOpenProps` stamped `popovertarget` on the control and scheduled its
removal from a `pointerup` listener alone. A press has two ends: a touch the
browser takes over for a scroll, and a long press that opens the platform
menu, both fire `pointercancel` and no `pointerup`. The attribute survived
the press, and Chrome's a11y tree then reported the clear button as an
expanded pop-up button — the permanent invoker the comment above the handler
rules out. The `{once: true}` listener leaked with it.
Both ends now share one cleanup, so whichever arrives first removes the
attribute and both listeners. That is what the other press cleanups in core
do: ResizeHandle, useTableColumnResize, usePointerDragScroll, useSheetGestures.
Driven in Chromium — touchStart then touchCancel, no touchEnd, on the clear
button of an open MultiSelector — the AX node loses `expanded=true` and the
attribute is gone; an ordinary tap still clears the value with the menu open.
|
Fixed the Driven the way you found it: CDP Both sides still report Chromium only: WebKit will not launch on this machine today, and WebKit is the engine most likely to claim the gesture, so that row is still unverified.
|






Fixes the trigger side of #5004.
The mechanism
A light dismiss and the trigger's own click are one press. The browser dismisses an
autopopover onpointerupand queues thetoggleevent; theclickfollows a beat later. Which of the two React sees first is a race:toggletogglefirst, then clickDropdownMenuandPopovereach carried a private 50 ms timing guard against this;Selector,MultiSelectorandComplexSelectorcarried nothing. A guard is also the wrong shape: a window is a guess about how long the two halves of one press can be apart, and the case where the race is lost — a blocked main thread — is exactly the case where the window is too short. I saw that directly: at 50 ms the new tests pass alone and fail when the suites run in parallel.The fix
Count gestures, don't time them. A tiny module counts
pointerdownandkeydownon the document; the layer records which gesture the browser dismissed it in, andwasJustDismissed()is true only while that same gesture is still in flight. A click from the dismissing press is absorbed however long the main thread was blocked; a deliberate second press is a new gesture and always acts. No constant to tune. Both hand-rolled copies collapse into it.The same dismissal also fires for controls that sit on the trigger — the clear ✕ and the status button. They live outside the popover, so pressing one dismissed the popup, which means the affordance and the popup it belongs to could never be used together.
keepOpenPropsnames such a control an invoker of the popover, which puts it inside the layer for that decision. The attribute is stamped for the press only: a permanent invoker reports itself asexpandedto assistive tech, which I confirmed in Chrome's AX tree — a Clear button must not.Test plan
Unit — 8 new tests, all red against unmodified source (verified by restoring the source files from
mainand rerunning): the trigger click absorbed when the dismissal lands first, a deliberate second press still acting, a programmatic hide left unguarded, the invoker stamped for the press and removed after, the invoker's own toggle cancelled, and one regression test each forSelector,MultiSelectorandComplexSelectordriving the losing order directly.packages/core: 6318 passing (the one failure is the Table 500-row perf budget, which flakes under parallel load here and passes in isolation).Real browsers — Playwright, Chromium and WebKit iPhone touch, pressing the clear ✕ and the status button of an open menu:
placement="below")Trigger toggling is unchanged in both engines: one press, one state change, and the third press reopens.
On the race itself: I could not make Chromium or WebKit lose it on this machine — both consistently deliver the click first, which is why the bug reads as intermittent and environment-dependent. The regression tests therefore drive the losing order explicitly rather than hoping for it. If you can reproduce the reopen somewhere concrete, that environment is the real check on this.
Notes
useSelectedItemOffsetand the overlay geometry are untouched here.Typeahead,Tokenizer,DateInput,DateRangeInput,TimeInput— have the same shape and are not wired here; their clear buttons should takekeepOpenPropstoo once someone confirms which of them can have a clear button visible with the popup open.Selector's clear and status buttons are covered by the menu entirely, so this fix is not visible there — that is the geometry half of #5004, left alone deliberately.