Skip to content

[popups] Fix rendered trigger id ownership - #5110

Merged
atomiks merged 6 commits into
mui:masterfrom
atomiks:codex/fix-tooltip-render-id
Jun 24, 2026
Merged

[popups] Fix rendered trigger id ownership#5110
atomiks merged 6 commits into
mui:masterfrom
atomiks:codex/fix-tooltip-render-id

Conversation

@atomiks

@atomiks atomiks commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Fixes #5108

A trigger rendered with an element that already has its own id (e.g. render={<button id="custom-button" />}) detaches the trigger's DOM id from the internal id Base UI registers it under. The active trigger is tracked by DOM id (setPopupOpenState reads trigger.id), while the registry and ownership selectors key off the internal id, so the trigger is never recognized as active. This affects every popup family that shares useImplicitActiveTrigger, at two severity levels:

Affected components & severity

🔴 Closes immediately after opening — Tooltip, Preview Card

Both enable closeOnActiveTriggerUnmount, so the failed registry lookup is treated as the active trigger unmounting and the popup closes right after it opens. This is the #5108 regression introduced in #4886 ([tooltip][preview card]) — 1.6.0 fails, 1.5.0 works.

🟡 Opens, but trigger ownership / ARIA is wrong — Popover, Menu, Dialog

No close-on-unmount, so the popup stays open, but the trigger never registers as active: data-popup-open is missing and aria-expanded / aria-controls are stale. This one is pre-existing (independent of #4886), surfaced by the same id mismatch.

Passing the id directly to the trigger (<Popover.Trigger id="custom-button">) instead of to the rendered element sidesteps all of this and already works on 1.6.0.

Root cause

setPopupOpenState records activeTriggerId from the trigger's DOM id. When a render id overrides Base UI's internal id, that value is not in the trigger registry, so getById misses and the ownership selectors (isOpenedByTrigger, etc.) never match the trigger.

Fix

In useImplicitActiveTrigger, when the active id misses a direct registry lookup, scan for the same element registered under a different id and reassociate to the registered id instead of treating it as lost. The reconciliation is also made reactive to active-trigger-id changes so it covers trigger-to-trigger handoffs while the popup stays open.

Perf

The scan only runs after the fast getById lookup misses, and it is self-healing (subsequent lookups hit the fast path). Measured across the multi-trigger suite: 0 scans in normal flows (108 effect runs → 5 scans total, all in the rare custom-id / lost-trigger paths), ~1.4 iterations per scan, bounded by the number of registered triggers.

Changes

  • Reassociate stale active trigger ids with the registered element before closing; rerun the reconciliation on active-trigger handoffs.
  • Add Tooltip store + component coverage for rendered trigger ids and handoffs.
@atomiks atomiks added type: regression A bug, but worse, it used to behave as expected. component: tooltip Changes related to the tooltip component. labels Jun 24, 2026
@pkg-pr-new

pkg-pr-new Bot commented Jun 24, 2026

Copy link
Copy Markdown

commit: 387277f

@code-infra-dashboard

code-infra-dashboard Bot commented Jun 24, 2026

Copy link
Copy Markdown

Bundle size

Bundle Parsed size Gzip size
@base-ui/react 🔺+208B(+0.04%) 🔺+37B(+0.02%)

Details of bundle changes

Performance

Total duration: 1,415.74 ms +140.99 ms(+11.1%) | Renders: 78 (+0)

Test Duration Renders
Menu mount (300 instances) 166.95 ms 🔺+29.21 ms(+21.2%) 2 (+0)
Menu open (500 items) 89.57 ms 🔺+22.82 ms(+34.2%) 12 (+0)
Select open (500 options) 68.69 ms 🔺+12.69 ms(+22.7%) 14 (+0)
Combobox type — 500 items, narrows to ~11 (type "Row 25") 38.81 ms 🔺+9.35 ms(+31.8%) 17 (+0)

10 tests within noise — details

Metric alarms

Test Metric Change
Menu mount (300 instances) bench:paint 🔺 +39.44 ms
Menu open (500 items) bench:paint 🔺 +38.01 ms
Menu open (500 items) bench:paint#menu-open 🔺 +38.01 ms
Select open (500 options) bench:paint 🔺 +24.54 ms
Select open (500 options) bench:paint#select-open 🔺 +24.54 ms
Combobox type — 500 items, narrows to ~11 (type "Row 25") bench:paint#combobox-open 🔺 +14.52 ms
Combobox type — 500 items, narrows to ~11 (type "Row 25") bench:paint 🔺 +14.52 ms

Check out the code infra dashboard for more information about this PR.

@netlify

netlify Bot commented Jun 24, 2026

Copy link
Copy Markdown

Deploy Preview for base-ui ready!

Name Link
🔨 Latest commit 387277f
🔍 Latest deploy log https://app.netlify.com/projects/base-ui/deploys/6a3bbee488b2b80008a5e342
😎 Deploy Preview https://deploy-preview-5110--base-ui.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@atomiks
atomiks marked this pull request as ready for review June 24, 2026 05:57

@flaviendelangle flaviendelangle left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

PR review

This is a tight, well-targeted fix for #5108 (tooltip triggers rendered with a custom id closing immediately after opening). The reassociation logic in useImplicitActiveTrigger is correct, self-limiting (slow-path only, runs once per open), and properly regression-tested at both the store and component level. Nothing here is merge-blocking. Two informational notes below: a one-time viewport content remount that this fix newly introduces for a narrow combination, and a stale function-level comment.

Bugs (0)

No findings.

Tests (0)

No findings.

Simplifications (0)

No findings.

Docs (1)

1. ℹ️ note — function comment doesn't mention the new reassociation path

Location: packages/react/src/utils/popups/popupStoreUtils.ts:323-339

 * When a popup opens without an explicit trigger id and exactly one trigger is registered, that
 * trigger is claimed as the active trigger. When the active trigger id is still registered but its
 * element changed, the active element is refreshed. When the active trigger unregisters, the
 * default path preserves existing ownership so non-closing popup families do not silently claim a
 * different trigger while staying open.

The JSDoc enumerates the three existing reconciliation paths but not the new one this PR adds: when the active trigger id is not found by direct lookup, the registry is scanned for the same element under a different id and the active id is reassociated (the whole point of the fix). The non-obvious branch — a DOM id differing from Base UI's internal trigger id — is exactly the kind of thing the next maintainer will want explained here.

Failure scenario: A future reader sees the getById miss fall through to a loop with no comment explaining why an element can be registered under an id other than activeTriggerId, and either misreads it as dead code or breaks it during refactor.

Fix: Add a sentence, e.g. "When the active trigger id is missing from the registry but the same element is registered under a different id (e.g. the rendered element carries its own DOM id), the active id is reassociated to the registered id instead of being treated as lost."

Verdict

Approve — correct, minimal fix with solid regression coverage; only an optional comment update and one narrow heads-up below.


A heads-up worth recording even though it doesn't rise to a finding: the reassociation flips activeTriggerId from the DOM id to the internal registered id once, just after open. For consumers of <X.Viewport> (usePopupContentKey keys its remount on activeTriggerId), a trigger rendered with a custom id will now produce a single content remount right after opening. For Tooltip/PreviewCard (closeOnActiveTriggerUnmount: true) this is strictly better than the prior behavior (full close). For Popover/Menu it's a new one-time remount in exchange for correctly marking the trigger active (isOpenedByTrigger / data-popup-open were previously wrong in this case). It's an acceptable trade, but if the remount ever matters, keying usePopupContentKey on activeTriggerElement identity instead of activeTriggerId would be stable across the reassociation.


🤖 Review generated with Claude Code

const reactiveTriggerCount = store.useState('triggerCount');
// Subscribe to the active trigger id so the reconciliation below reruns when ownership moves to
// another trigger while the popup stays open (e.g. a focus/hover handoff between triggers).
const reactiveActiveTriggerId = store.useState('activeTriggerId');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit: I'm not fan of this name. Could we call it just activeTriggerId (and rename what's currently activeTriggerId in effects to currentActiveTriggerId - there are some places that already use this convention)

@atomiks atomiks added the component: preview card Changes related to the preview card component. label Jun 24, 2026
@atomiks atomiks changed the title [tooltip] Fix rendered trigger id ownership Jun 24, 2026
@atomiks atomiks changed the title [tooltip][preview card] Fix rendered trigger id ownership Jun 24, 2026
@atomiks atomiks added component: popover Changes related to the popover component. component: menu Changes related to the menu component. component: dialog Changes related to the dialog component. and removed component: menu Changes related to the menu component. component: tooltip Changes related to the tooltip component. component: dialog Changes related to the dialog component. labels Jun 24, 2026
@atomiks atomiks added scope: all components Widespread work has an impact on almost all components. and removed component: popover Changes related to the popover component. component: preview card Changes related to the preview card component. labels Jun 24, 2026
@atomiks
atomiks merged commit e0c1119 into mui:master Jun 24, 2026
33 checks passed
@atomiks
atomiks deleted the codex/fix-tooltip-render-id branch June 24, 2026 11:46
@ciampo

ciampo commented Jun 24, 2026

Copy link
Copy Markdown

@atomiks and @michaldudak , do you intend to release a point release that includes this fix? Or will it be part of 1.7.0?

@atomiks

atomiks commented Jun 25, 2026

Copy link
Copy Markdown
Contributor Author

@ciampo whether a patch release goes out before 1.7.0 depends on how often this is actually hit — I'm not sure how common putting an id on the element passed to the render prop of a tooltip trigger is (my guess for the most commonly affected case would be form inputs with a <label> htmlFor link).

The workaround for the time being is simple at least: move the id prop to the <Tooltip.Trigger> component itself.

@ciampo

ciampo commented Jun 25, 2026

Copy link
Copy Markdown

@atomiks sounds good, thank you for the reply

@Kosai106

Copy link
Copy Markdown

@atomiks this issue is hurting users, why would a patch release not be the best solution here?

@atomiks

atomiks commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

@Kosai106 for context we limit stable releases to once per month and reserve patch releases for widespread regressions without a practical workaround. So far, we’ve received relatively few reports of this issue, and there is a straightforward workaround.

If the workaround isn’t viable (placing the id on the component directly, instead of the rendered element), we publish a pkg.pr.new canary build for every commit, so the fix can be used immediately when you need it, without needing to wait for a release.

The next stable release is scheduled for next week as well, so the fix will be live shortly for everyone. (This release came a little later than usual because we’ve shifted our monthly cadence to the first week of each month)

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

Labels

scope: all components Widespread work has an impact on almost all components. type: regression A bug, but worse, it used to behave as expected.

5 participants