[Bug Fix] Popover: set data-state/data-side, clear closeTimeout on disconnect, close on Escape - #495
Merged
Merged
Conversation
…sconnect, close on Escape (#494)
djalmaaraujo
marked this pull request as ready for review
July 30, 2026 17:01
There was a problem hiding this comment.
All reported issues were addressed across 5 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
There was a problem hiding this comment.
All reported issues were addressed across 3 files (changes from recent commits).
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
There was a problem hiding this comment.
All reported issues were addressed across 3 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
cirdes
approved these changes
Jul 30, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Related issue
Related to #494
Description
Brings
Popoverup to the standardHoverCardgot in #438.popover_controller.jshad not been touched since the monorepo unification (#358), soPopoverContentwas shipping eight Tailwind variant classes that could never match, plus a timer that outlived the controller.Four changes, each mirroring what
hover_card_controller.jsalready does rather than inventing a new approach:data-state—PopoverContentnow server-rendersdata-state="closed"(asHoverCardContentdoes), and the controller setsopen/closedinshowPopover/hidePopover. This is what makesdata-[state=open]:animate-in,fade-in-0andzoom-in-95actually resolve; before this they were dead CSS.data-side—updatePositionnow readsplacementback out ofcomputePositionand writesdataset.sidefrom it. Reading the resolved placement matters specifically becauseflip()is in the middleware chain, so the popover can land on the opposite side of the requested one; only the resolved value makes thedata-[side=*]:slide-in-from-*classes point the right way.closeTimeout— cleared indisconnect(), andthis.cleanupis nulled after being invoked. Without this, a disconnect+reconnect inside the 100 ms hover-close window leaves the dead instance's timeout pending; it then resolvescontentTargetto the same re-attached element and hides it, closing a popover the fresh instance had just opened. No console error — the popover just snaps shut.Escape— closes the popover, with thedocumentkeydown listener added on open and removed on close and on disconnect.HoverCardandDropdownMenuboth already do this; without it, atrigger: "click"popover has no keyboard dismissal at all.One extra change fell out of testing the third item.
disconnect()used to start with the element-listener teardown, which resolvesthis.triggerTarget/this.contentTarget— and resolving a target throws once that element is gone. Stimulus catches the throw and logsError disconnecting controller, so the page survives, but every line after it indisconnect()is skipped:clearTimeoutnever runs and the pending close leaks anyway, which defeats the fix.disconnect()now runs the teardown that cannot fail first (timer, bothdocumentlisteners, theautoUpdatecleanup) and touches targets last. The document-levelclickremoval moved out of the element teardown for the same reason, so the method is now element-only and renamedremoveElementEventListenersto say so.Review round follow-up: the ordering rule above was applied in
disconnect()but not consistently, and three related gaps were fixed on top.hidePopover()had the same hazard — it mutatedcontentTargetbefore releasing the keydown listener and theautoUpdatecleanup, so losing the content target while the controller stayed connected leaked both. It now releases first and guards the content mutation.removeElementEventListeners()dereferenced both targets unconditionally, so losing only the content target left the trigger'sclicklistener attached and pointing at a dead controller. Each target is now guarded separately.openValueis stored in the DOM, so a reconnect arrives already open — butconnect()never re-armed the keydown listener or the positioning, leaving a popover that looked open while Escape did nothing and the position was frozen.connect()now callsshowPopover()whenopenValueis true.autoUpdatecallback also bails out instead of throwing if a target disappears before anything tears the popover down.A second round tightened the same area further:
updatePosition()captures the exact trigger/content pair it positions instead of re-reading the targets, and both theautoUpdatecallback and thecomputePositioncontinuation checkisConnectedbefore touching them — a target detached or swapped mid-flight is skipped rather than throwing or being written to while stale.autoUpdatehandle is now actually released through a sharedstopAutoUpdate()(also used byhidePopover()anddisconnect()), rather than the callback merely returning and leaving the observers firing on every scroll and resize. The release is deferred withqueueMicrotaskbecauseautoUpdateinvokes its callback once synchronously, before the handle has been assigned.openValueis no longer assigned by the callers.showPopover()sets it true only after its guard passes andhidePopover()sets it false, so a guarded early return can't leave the DOM claiming open while nothing is wired up — and a click while the content target is missing no longer wedges the popover permanently closed. As a side effect the five duplicatedopenValue = ...; show/hidepairs collapse into the two methods that own the transition.connect()'saddEventListeners()is deliberately left unguarded: markup missing a trigger or content is an authoring error and should fail loudly. The guards are on the teardown and positioning paths, whose job is to release or update things and which therefore must not be able to fail.Escape stays an imperative
documentkeydown listener rather than a declarativekeydown.esc@document->action, for two reasons: the listener is only registered while the popover is actually open (a declarative action would register one per popover for the lifetime of the page — the docs page alone renders 14), and the gem ships this controller independently of the Ruby components, so behavior kept in the controller keeps working for consumers who write their own markup. It also matches howHoverCardandDropdownMenualready handle Escape.Both copies of the controller are updated (
gem/lib/ruby_ui/popover/anddocs/app/javascript/controllers/ruby_ui/) — they were byte-identical and letting them drift is what #490 had to repair forAccordion.mcp/data/registry.jsonis regenerated, since it embeds the component source and the CI freshness check fails otherwise.Deliberately out of scope
hidden(display: none) lands in the same frame asdata-state="closed", soanimate-outstill can't be seen.HoverCardandDropdownMenushare this exact limitation; onlyTooltipsolves it, by deferring unmount onanimationend. Fixing it properly is a cross-component change and belongs in its own issue.hiddenclass is already absent in the snapshot, so this predates the change and is not made worse by it.Tooltipis the only component that unmounts onturbo:before-cache.Testing instructions
cd gem && bundle exec rakecovers the server-rendered attribute (281 runs, plus standardrb over 402 files). The behavior is in the Stimulus controller, so the rest needs a browser — the docs app has no system tests, so these are manual steps.Start the docs app (
cd docs && bin/dev) and open http://localhost:3000/docs/popover.1. Enter animation now runs (the
data-statefix)data-state="open". Move the mouse away and it flips todata-state="closed".main, the same element has nodata-stateattribute at all and the panel pops in with no transition.2.
data-sidefollows the resolved placementtop,right,leftandbottomtriggers in turn.data-side(top/right/left/bottom) and slide in from the opposite edge.toppopover has no room above it —flip()moves it below anddata-sidemust follow tobottom, so the slide direction inverts with it. This is the case the requested-placement value would get wrong.3. Escape closes it, and it reopens afterwards
options: { trigger: 'click' }), click "Click" to open.4. The stale-timer bug (the reason for the
disconnectchange)In the devtools console on the popover docs page:
Expected:
hidden: false— the popover stays open. Onmainthis logshidden: true: the dead instance's timeout fired and closed it.5. Teardown survives a missing target (the
disconnectordering)Same console, fresh reload. Here the trigger is removed while the close is pending, so the element teardown throws but
contentTargetstill resolves — which makes it visible whetherclearTimeoutgot to run:Expected:
state: open— the timer was cleared before the throw. With the teardown in the old order this logsstate: closed, i.e. the stale close still fired. Stimulus will logError disconnecting controllereither way: the DOM really is missing a target at that point, and that part is pre-existing (addEventListenersthrows the same way onconnect, unguarded, onmaintoo) — what changed is that it no longer costs us the cleanup.6. Reconnect keeps a popover fully functional, not just visually open
Expected:
state: closed— Escape still works on the reconnected instance. Without theconnect()re-arm this logsstate: open: the popover is visibly open but nothing is listening.7. Losing only the content target does not strand the trigger
Expected: no
Missing target element "content"error in the console from the click. Without the per-target guards the trigger keeps its listener and the click reaches a disconnected controller.8. No regressions in the other suites
cd docs && bundle exec standardrb && bin/rails db:test:prepare test(75 runs)cd mcp && bundle exec rake test(24 runs), andbundle exec exe/ruby-ui-mcp-buildmust leaveregistry.jsonunchanged