-
Notifications
You must be signed in to change notification settings - Fork 0
Review 5018
cixzhang · open · view on GitHub
Verdict: request changes · 3 rounds
These records predate the Review Presentation format for the most part, so the problem/solution/API/theme-target/breaking slots are only filled where the review itself carried them. Reviews from 2026-08-23 on use the full template.
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
toggle(). Anything callingshow()/hide()directly bypasses it — ComplexSelector is the example after the merge, and its new test passes off its own leftover 50ms timer rather than this change. Guarding inshow()would make it hold for every caller instead of being something each one has to opt into.
dismissedByGestureRefis never cleared (useLayer.tsx:485), so a click with no pointerdown before it — AT activation,.click()— is read as part of the dismissing gesture indefinitely. Make it one-shot.
keepOpenPropsisn't finished.KeepLayerOpenPropsis unexported,InputClearButtongrew two ad-hoc handler props, and five clearable inputs are unwired — let's keep it internal until Typeahead and the date/time inputs are done rather than ship a half-built API.[Reviewed by Robohands and the maintainer]
Merged
mainand rewrote the guard against your three points. Pushed to this branch (4947e62).The guard moved into
show(). Every caller lands there, so a trigger no longer opts in —wasJustDismissed()is checked before the layer opens, whatever route the caller took. ComplexSelector's private 50 ms timer is deleted with it. Its two tests now fail when I remove theshow()guard and pass with it, so they are testing this change and not the timer.The dismissal is one-shot. It is spent by the click that ends the press it came from — a one-shot bubble-phase
clicklistener on the document, so every guard reading it has already run. A click with no press of its own (AT activation,element.click()) therefore acts. New test:acts on a synthesized click with no press of its own, red without the clear.One more defect the guard exposed.
hide()calledhidePopover()whileisOpenRefstill readtrue, so atoggleevent delivered synchronously was read as a browser dismissal — a programmatic hide stamped the gesture. The ref is cleared before the call now, which is what the comment above the reconciler already claimed. DropdownMenu's old race test was passing off exactly that bug (three clicks, no pointerdown, no light dismiss); it is rewritten to drive the losing order, plus a second test that a press of its own re-opens.Playwright, trusted presses,
pnpm storybook. Chromium deliversclickbeforetoggleon both mouse and touch (pointerdown → click → toggle:closed), so it wins the race on its own — as you found. To test the losing order I hand React the dismissal first, from a real press: a bubble-phasepointerdownlistener dispatchestoggle: closedon the open popover, then the press's own click arrives. Before/after is the same browser, same driver,main's files swapped in for "before":
trigger press while open before after DropdownMenu closes (its own 50 ms timer) closes Popover re-opens closes Selector re-opens closes MultiSelector re-opens closes ComplexSelector closes (its own 50 ms timer) closes Also in Chromium, with the fix: third press re-opens everywhere; a synthesized
.click()after a dismissing press opens the menu; an outside press dismisses; MultiSelector's clear ✕ clears the value with the menu still open.
Layer, Popover, DropdownMenu, Selector, MultiSelector, ComplexSelector, Field, DateInput, Typeahead, Tooltip, HoverCard, Dialog, CommandPalette— 1011 passing. Fullpackages/coreis green apart fromDateInputTouchtimeouts under parallel load, which reproduce identically on the merge commit before this change (they pass in isolation, 133/133). Typecheck clean, lint 0 errors.Merging this branch into #4881 auto-merges
usePopover.tsxcleanly — the one file both touch. That merge does conflict inLayer/index.ts,Tooltip/useTooltip.tsxanduseHoverCard.tsx, but those conflicts are #4881 against today'smainand appear with this branch left out. Nothing here is subsumed or duplicated either: #4881 routes Escape and the close-watcher through a stack of layers, and pointer light dismiss stays the browser's, so thetogglethis guard counts on still fires. Order can be whichever suits you.
keepOpenPropsis untouched — your third point (unexported type, two ad-hoc props onInputClearButton, five unwired inputs) is an API-scope call, and I did not want to make it on your behalf inside a correctness fix.[Pushed by Robohands]
Full artifact: {review-root}/review-artifacts/pr-5018/gate-r1.md on the M5.
Head reviewed 4947e624, re-verified unmoved immediately before posting.
| slot | |
|---|---|
| problem | the button of an open menu closes it and it comes straight back — the dismissal and the click are one press, and which half React sees first is a race. Second, stated problem: the clear ✕ and status ⚠ sit on the trigger but outside the popover, so pressing one dismissed the popup |
| solution | 2 decisions: (1) count gestures and absorb the dismissing press inside show(), deleting three private 50 ms timers, with the one-shot clear and the hide()-before-hidePopover() correction riding along; (2) keepOpenProps names a control on the trigger an invoker for the press |
| API |
keepOpenProps + wasJustDismissed on ContextLayerReturn, FixedLayerReturn and UsePopoverReturn — all public through the core barrel; KeepLayerOpenProps exported from the module but not from Layer/index.ts or the barrel; two ad-hoc handler props on InputClearButton; neither new member in useLayer.doc.mjs or usePopover.doc.mjs
|
| theme targets | none — structural diff, greps for colour/style and for aria/role/strings both zero in runtime source |
| ossification | no other hook in core hands out a "do not dismiss me" props bag, so this is a new concept, and two of seven clearable inputs are wired. [Needs human judgement]
|
| breaking | API no · visual no · theme no. Behaviour: one change I could not reach from a shipped configuration — a controlled Popover whose consumer holds isOpen true through a light dismiss now stays closed until the next render (Popover.tsx:489-497 against the new show() guard). Recorded, not filed |
| evidence | real Chromium, both sides in one session, warm main b0a09a630f5 on :6100 vs the head on storybook dev :6337. The re-open reproduces on main and is gone here; clear ✕ and status ⚠ keep the menu open where main closes it; frames published to assets/pr-5018 on the fork. Probes banked: probe-5018-trigger-clear.cjs, probe-5018-hit-ownership.cjs, probe-5018-below-placement.cjs. WebKit could not be driven — it will not open a page on this host |
| R31c |
main moved: #4881 landed after the merge-base. It leaves useLayer.tsx alone and owns Escape only, so the fix survives — verified by trial merge, 550 green tests on the merged tree, and re-running the Chromium check there |
The block. keepOpenProps.onPointerDown stamps popovertarget and removes it from a
pointerup listener only (useLayer.tsx:691). A press that ends in pointercancel — a touch the
browser takes over for a scroll, a long-press — leaves it, and Chrome's AX tree then reports the
clear button expanded=true: the exact state the PR body rules out. All four other press
cleanups in packages/core pair pointerup with pointercancel; this is the fifth and does not.
grep -c pointercancel over the whole diff is 0.
Held for the maintainer. Whether keepOpenProps ships as public surface now or stays internal
until the other five clearable inputs are wired — our own ask from round 1, which the author
deliberately declined to answer inside a correctness fix. The loop's recommendation: keep it
internal, and land the guard half alone.
https://github.com/facebook/astryx/pull/5018#issuecomment-5404962367
Thanks — the two you took from last round hold up. In Chromium, with the dismissal handed to React before the click,
mainre-opens the menu and this closes it:
before after press the trigger of an open menu, dismissal first press the clear ✕ with the menu open press the status ⚠ with the menu open One to fix before it lands: after a touch the browser cancels — a press turned into a scroll, a long-press — Chrome's a11y tree has the clear ✕ as an expanded pop-up button, the state your note rules out.
keepOpenPropstakes thepopovertargetoff onpointeruponly (useLayer.tsx:691), and a press has two ends. Listen forpointercanceltoo; the four other press cleanups in core do (ResizeHandle.tsx:439,useSheetGestures.ts).
keepOpenPropsis still your call from last round; I'd keep it internal until the date/time inputs are wired. Send the guard on its own oncepointercancelis covered and it lands today — does the invoker half want to wait for them?[Reviewed by Robohands]
Nothing — posted as the second draft stood. The first draft was cut by the loop's own critic for two claims wider than its evidence (it credited itself with the author's Popover/Selector rows and with an AT-activation check it had not driven) and for 167 words against a 150-word cap; deleting the two unearned claims fixed both.





