Skip to content

fix(ds): keep DS::Menu/Popover panels anchored across Turbo morphs#2812

Open
gariasf wants to merge 1 commit into
mainfrom
worktree-dropdown-anchor-bug
Open

fix(ds): keep DS::Menu/Popover panels anchored across Turbo morphs#2812
gariasf wants to merge 1 commit into
mainfrom
worktree-dropdown-anchor-bug

Conversation

@gariasf

@gariasf gariasf commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • After any same-page account action (disable, exclude from reports, set default, etc.), the accounts page does a Turbo morph refresh (turbo_refreshes_with method: :morph). Idiomorph resets every DS::Menu/DS::Popover content panel's style attribute back to the bare server-rendered value, silently stripping the JS-applied position: fixed. The next dropdown opened (any row, not just the one acted on) briefly renders in normal flex flow before floating-ui's async recompute lands, shoving its own trigger button sideways — so the panel ends up anchored to a phantom position instead of the real button.
  • Related bug found in the same investigation: this.show was tracked as a plain instance property, which the morph can leave desynced from the DOM (menu closed in the DOM, but the controller still thinks it's open), silently swallowing the first click on a dropdown right after it caused the morph.
  • DS::Popover mirrors DS::Menu's implementation and has the identical issue, so it's fixed the same way.

Fix

  • menu.html.erb / popover.html.erb: add fixed to the content panel's static class list, so position: fixed survives a morph resetting the inline style (only left/top remain JS-driven).
  • menu_controller.js / popover_controller.js: derive show from contentTarget.classList.contains("hidden") instead of tracking it separately, so it can never desync from the DOM.

Before / After

Real app, fixture/demo data (not production data): disable "Checking Account" from its own dropdown, then open the unrelated "Credit Card" row's dropdown, which was never touched.

Before Credit Card row's dropdown menu (Edit, Sharing, Link with provider, Disable account, ...) rendered near the top of the page, nowhere near the Credit Card row's own trigger button at the bottom of the viewport
After Same dropdown menu now anchored directly above the Credit Card row's trigger button, flipped upward since there wasn't room below

Test plan

  • Reproduced against the real Turbo 8.0.13 + Stimulus + floating-ui pipeline (pulled from the project's own turbo-rails/stimulus-rails gems and vendor/javascript) in an isolated harness, confirmed the exact failure mode, then confirmed it's resolved with the patched controller (panel lands pixel-exact on its trigger; previously-swallowed first click now opens on click 1).
  • erb_lint clean on both templates.
  • node --check clean on both controllers.
  • Full bin/rails test — 6022 runs, 0 failures, 0 errors.
  • Reproduced live in a real browser against fixture data (screenshots below), confirming both the anchor bug on main and the fix on this branch.

Summary by CodeRabbit

  • Bug Fixes

    • Improved menu and popover behavior after page updates or navigation.
    • Ensured open and closed states stay synchronized with what is displayed.
    • Improved accessibility state updates for expandable menus and popovers.
  • Style

    • Updated menu and popover positioning for more consistent placement and layout.

The app refreshes pages via Turbo morph (`turbo_refreshes_with method:
:morph`), and same-page account actions (disable, exclude, set-default,
etc.) trigger one. Two bugs in the shared floating-ui controllers surface
as a result:

- The panel's `position: fixed` only ever existed as a JS-applied inline
  style. Idiomorph resets every menu/popover's `style` attribute to match
  the server-rendered markup (which has none), silently stripping
  `position: fixed` from every panel on the page. The next dropdown/
  popover opened before floating-ui's async recompute lands briefly
  renders in normal flex flow, shoving its own trigger sideways and
  making computePosition anchor to that phantom position instead of the
  real button. Fix: make `position: fixed` part of the static markup so
  it can never be stripped.

- `this.show` was a plain instance property. Because the morph preserves
  the Stimulus controller in place (stable-id turbo frame), `this.show`
  doesn't reset when idiomorph re-closes the content element, so it can
  desync from the DOM and swallow the next click. Fix: derive `show` from
  the content element's own class instead of tracking it separately.

Reproduced and verified against the actual Turbo/Stimulus/floating-ui
pipeline in an isolated harness before and after the fix.
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Menu and popover content containers now use fixed positioning. Their controllers derive visibility from the DOM’s hidden class instead of mutable instance state, keeping toggle and close behavior synchronized after Turbo updates.

Changes

DS overlay state and positioning

Layer / File(s) Summary
Menu positioning and DOM-derived state
app/components/DS/menu.html.erb, app/components/DS/menu_controller.js
The menu content is fixed-positioned, and visibility is derived from the content element’s hidden class for toggle and close operations.
Popover positioning and DOM-derived state
app/components/DS/popover.html.erb, app/components/DS/popover_controller.js
The popover content is fixed-positioned, and visibility is derived from the content element’s hidden class for toggle and close operations.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related PRs

  • we-promise/sure#1850: Changes related DS menu/popover controller visibility and ARIA state behavior.

Suggested reviewers: jjmata

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: keeping DS::Menu and DS::Popover panels anchored across Turbo morphs.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch worktree-dropdown-anchor-bug

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
app/components/DS/menu_controller.js (1)

32-40: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add regression coverage for DOM-derived overlay state.

  • app/components/DS/menu_controller.js#L32-L40: test morphing an open menu back to hidden, then toggling it open again.
  • app/components/DS/menu_controller.js#L115-L127: assert the reopened menu sets aria-expanded="true".
  • app/components/DS/popover_controller.js#L33-L41: test the equivalent popover morph path.
  • app/components/DS/popover_controller.js#L79-L90: assert the reopened popover sets aria-expanded="true".
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/components/DS/menu_controller.js` around lines 32 - 40, Apply regression
coverage at app/components/DS/menu_controller.js:32-40 and
app/components/DS/menu_controller.js:115-127 by morphing an open menu to hidden,
toggling it open again, and asserting aria-expanded="true"; add the equivalent
popover coverage at app/components/DS/popover_controller.js:33-41 and
app/components/DS/popover_controller.js:79-90, including the reopened
aria-expanded assertion.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@app/components/DS/menu_controller.js`:
- Around line 32-40: Apply regression coverage at
app/components/DS/menu_controller.js:32-40 and
app/components/DS/menu_controller.js:115-127 by morphing an open menu to hidden,
toggling it open again, and asserting aria-expanded="true"; add the equivalent
popover coverage at app/components/DS/popover_controller.js:33-41 and
app/components/DS/popover_controller.js:79-90, including the reopened
aria-expanded assertion.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2b50d66a-b0f3-449a-a55b-c3fd30adf683

📥 Commits

Reviewing files that changed from the base of the PR and between c9a28e1 and 7e2396c.

📒 Files selected for processing (4)
  • app/components/DS/menu.html.erb
  • app/components/DS/menu_controller.js
  • app/components/DS/popover.html.erb
  • app/components/DS/popover_controller.js

gariasf added a commit that referenced this pull request Jul 26, 2026

@jjmata jjmata left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice diagnosis — a Turbo morph resetting the server-rendered style attribute stripping the JS-applied position: fixed, combined with show being a plain instance property that can desync from the DOM after a morph (silently swallowing the next click), are both subtle bugs that would be easy to misdiagnose as "flaky floating-ui positioning."

The fixes address both root causes directly rather than patching symptoms:

  • Moving position: fixed into the static class list means it survives morph's inline-style reset, while left/top stay JS-driven as before.
  • Deriving show from contentTarget.classList.contains("hidden") instead of tracking separate state makes desync structurally impossible — it can't drift from the DOM because it is the DOM.

Applying the identical fix to DS::Popover since it mirrors DS::Menu's implementation is the right move rather than leaving a latent duplicate bug.

One soft suggestion: the verification here was a manual/isolated-harness reproduction against the real Turbo/Stimulus/floating-ui stack plus a live browser check — solid, but if there's a reasonably cheap way to script the "trigger a morph, then open a different row's dropdown" sequence as a system test, it'd guard against regression here specifically (this class of morph-timing bug is easy to reintroduce). Not a blocker given how thoroughly it was manually verified.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants