Skip to content

fix(headless): stop a menu inside a popover acting as a submenu - #9315

Merged
alexcarpenter merged 2 commits into
mainfrom
carp/menu-role-in-popover
Aug 3, 2026
Merged

fix(headless): stop a menu inside a popover acting as a submenu#9315
alexcarpenter merged 2 commits into
mainfrom
carp/menu-role-in-popover

Conversation

@alexcarpenter

Copy link
Copy Markdown
Member

Description

A Menu rendered inside a Popover was treating itself as a submenu. Its trigger got role="menuitem", opened on hover, placed itself to the side, and ignored mouse clicks, even though the popover it sits in is a role="dialog" with no menu anywhere.

Both symptoms come from the same assumption: that a floating parent is a menu parent. FloatingTree is generic plumbing that every floating element joins so useDismiss knows a click inside a child isn't an outside press, so a popover ancestor sets parentId just like a parent menu does.

Two places had to learn the difference:

  • MenuRoot now derives isNested from parentId != null && parentContext != null, so a submenu is a menu with a parent menu.
  • useRole re-derives nesting internally from useFloatingParentNodeId() alone and takes no override, so its role="menuitem" reference prop is dropped when there is no parent menu. Everything else it emits (aria-expanded, aria-haspopup, aria-controls, id, and the whole floating side) is correct in both cases and is left alone.

The floating tree itself is untouched. Giving the menu its own tree would make useRole self-correct, but the popover would then read clicks on menu items as outside presses and close underneath them.

role="menuitem" on a trigger with no menu ancestor is also wrong for assistive tech, which announces it as an item of a set that does not exist.

Checklist

  • pnpm test runs as expected.
  • pnpm build runs as expected.
  • (If applicable) JSDoc comments have been added or updated for any package exports
  • (If applicable) Documentation has been updated

Type of change

  • 🐛 Bug fix
  • 🌟 New feature
  • 🔨 Breaking change
  • 📖 Refactoring / dependency upgrade / documentation
  • other:

A menu rendered inside a popover has a floating parent node id, so keying
nesting off that alone made it hover-open, place itself side-start, and
swallow mouse clicks. Require a parent menu context too.
A popover is not a menu, so a menu trigger rendered inside one is a plain button.
floating-ui derives submenu-ness from the shared floating tree, which every
floating element joins for dismissal, and labels the trigger a menu item of a
menu that does not exist. Take the role from whether there is a parent menu.
@changeset-bot

changeset-bot Bot commented Aug 3, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9beaf02

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 0 packages

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Aug 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
clerk-js-sandbox Ready Ready Preview Aug 3, 2026 4:58pm
swingset Ready Ready Preview Aug 3, 2026 4:58pm

Request Review

@pkg-pr-new

pkg-pr-new Bot commented Aug 3, 2026

Copy link
Copy Markdown

Open in StackBlitz

@clerk/astro

npm i https://pkg.pr.new/@clerk/astro@9315

@clerk/backend

npm i https://pkg.pr.new/@clerk/backend@9315

@clerk/chrome-extension

npm i https://pkg.pr.new/@clerk/chrome-extension@9315

@clerk/clerk-js

npm i https://pkg.pr.new/@clerk/clerk-js@9315

@clerk/electron

npm i https://pkg.pr.new/@clerk/electron@9315

@clerk/electron-passkeys

npm i https://pkg.pr.new/@clerk/electron-passkeys@9315

@clerk/eslint-plugin

npm i https://pkg.pr.new/@clerk/eslint-plugin@9315

@clerk/expo

npm i https://pkg.pr.new/@clerk/expo@9315

@clerk/expo-google-signin

npm i https://pkg.pr.new/@clerk/expo-google-signin@9315

@clerk/expo-passkeys

npm i https://pkg.pr.new/@clerk/expo-passkeys@9315

@clerk/express

npm i https://pkg.pr.new/@clerk/express@9315

@clerk/fastify

npm i https://pkg.pr.new/@clerk/fastify@9315

@clerk/hono

npm i https://pkg.pr.new/@clerk/hono@9315

@clerk/localizations

npm i https://pkg.pr.new/@clerk/localizations@9315

@clerk/nextjs

npm i https://pkg.pr.new/@clerk/nextjs@9315

@clerk/nuxt

npm i https://pkg.pr.new/@clerk/nuxt@9315

@clerk/react

npm i https://pkg.pr.new/@clerk/react@9315

@clerk/react-router

npm i https://pkg.pr.new/@clerk/react-router@9315

@clerk/shared

npm i https://pkg.pr.new/@clerk/shared@9315

@clerk/tanstack-react-start

npm i https://pkg.pr.new/@clerk/tanstack-react-start@9315

@clerk/testing

npm i https://pkg.pr.new/@clerk/testing@9315

@clerk/ui

npm i https://pkg.pr.new/@clerk/ui@9315

@clerk/upgrade

npm i https://pkg.pr.new/@clerk/upgrade@9315

@clerk/vue

npm i https://pkg.pr.new/@clerk/vue@9315

commit: 9beaf02

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The menu primitive now treats a menu as nested only when both a floating parent ID and a MenuContext exist. Actual submenus retain generated roles. Non-nested menu triggers lose their role attribute. A regression test covers a menu trigger inside a popover. A Changesets metadata file is also added.

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

Possibly related PRs

Suggested reviewers: austincalvelage, maxyinger

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main fix: preventing menus inside popovers from being treated as submenus.
Description check ✅ Passed The description directly explains the popover submenu bug, the implementation changes, and the related test coverage.
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.

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)
packages/headless/src/primitives/menu/menu.test.tsx (1)

563-589: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Cover the menu interaction change in this regression test.

Lines 563-589 open only the Popover. They do not click Actions. The change in packages/headless/src/primitives/menu/menu-root.tsx also controls hover, click, and placement through isNested. This test could pass while the trigger remains unclickable as a submenu.

After opening Account, click Actions and assert that Sign out is rendered.

Suggested assertions
       await user.click(screen.getByText('Account'));

+      await user.click(screen.getByRole('button', { name: 'Actions' }));
+      expect(screen.getByRole('menu')).toBeInTheDocument();
+      expect(screen.getByText('Sign out')).toBeInTheDocument();
+
       // A popover is not a menu, so its children are not menu items.

As per coding guidelines, unit tests are required for all new functionality and must verify behavior and edge cases.

🤖 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 `@packages/headless/src/primitives/menu/menu.test.tsx` around lines 563 - 589,
Extend the regression test around the nested Menu.Trigger by clicking the
visible “Actions” button after opening the Popover and asserting that the “Sign
out” menu item is rendered. Keep the existing plain-button and role assertions,
and verify the interaction path controlled by isNested rather than only checking
initial rendering.

Source: Coding guidelines

🤖 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 `@packages/headless/src/primitives/menu/menu.test.tsx`:
- Around line 563-589: Extend the regression test around the nested Menu.Trigger
by clicking the visible “Actions” button after opening the Popover and asserting
that the “Sign out” menu item is rendered. Keep the existing plain-button and
role assertions, and verify the interaction path controlled by isNested rather
than only checking initial rendering.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: b7489856-3077-45fa-be41-735b58a34503

📥 Commits

Reviewing files that changed from the base of the PR and between fdadaa2 and 9beaf02.

📒 Files selected for processing (3)
  • .changeset/menu-role-in-popover.md
  • packages/headless/src/primitives/menu/menu-root.tsx
  • packages/headless/src/primitives/menu/menu.test.tsx
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • clerk/clerk_go (manual)
  • clerk/dashboard (manual)
  • clerk/accounts (manual)
  • clerk/backoffice (manual)
  • clerk/clerk (manual)
  • clerk/clerk-docs (manual)
  • clerk/cloudflare-workers (manual)

@alexcarpenter
alexcarpenter merged commit 8b420c2 into main Aug 3, 2026
50 checks passed
@alexcarpenter
alexcarpenter deleted the carp/menu-role-in-popover branch August 3, 2026 17:32
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