Skip to content

[popover] Fix detached trigger store migration - #5442

Merged
atomiks merged 8 commits into
mui:masterfrom
atomiks:codex/fix-popover-stale-ref-registration
Aug 10, 2026
Merged

[popover] Fix detached trigger store migration#5442
atomiks merged 8 commits into
mui:masterfrom
atomiks:codex/fix-popover-stale-ref-registration

Conversation

@atomiks

@atomiks atomiks commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Fixes #5416

(Cause: #5149)

Base UI 1.7 changed a detached trigger's registration ref when its handle moved between the fallback and Root stores. A ref-merging component that retains the callback it was first given never calls the new one, so the trigger stays registered on the fallback store. Hover handoff resolves the destination trigger through the attached store's trigger map, finds nothing, and closes the popup instead of switching to the new trigger.

Changes

  • Make the trigger registration callback stable, so a retained downstream callback still reaches the trigger's current store.
  • Track the active registration as a (store, id, element) triple, so unregistering targets the store the element was registered in rather than whichever store the caller captured.
  • Move migration between stores into a layout effect keyed on [store, id], since a stable ref no longer re-fires on those changes.
  • Skip a register call that would re-register the same element under the same store and id.

Notes

A stable ref also fixes a second failure with the same precondition. Once a merger has retained the callback from before the trigger migrated, replacing the rendered host node re-invokes that callback. It closes over the previous store, so the live node returns to the fallback store while the attached store keeps the disconnected one. handle.open(triggerId) searches attached stores first, so the popup then anchors to a removed element. A component that merges refs correctly, or does not merge at all, was never affected.

Consumers' own refs are no longer detached and reattached when a trigger's handle moves between stores.

Tests

  • Hover handoff across detached triggers whose rendered component retains a stale ref. A non-zero closeDelay is what forces the handoff path: without it the popup closes and reopens, which passes even when the trigger is registered on the wrong store.
  • Host node replacement after a fallback-to-Root migration, asserting store contents and that handle.open() anchors to the live node.
  • A useTriggerRegistration unit test covering callback stability and unregistering from the correct store.

@atomiks atomiks added type: regression A bug, but worse, it used to behave as expected. component: popover Changes related to the popover component. labels Aug 10, 2026
@pkg-pr-new

pkg-pr-new Bot commented Aug 10, 2026

Copy link
Copy Markdown

commit: d2f2750

@code-infra-dashboard

code-infra-dashboard Bot commented Aug 10, 2026

Copy link
Copy Markdown

Bundle size

Bundle Parsed size Gzip size
@base-ui/react 🔺+80B(+0.02%) 🔺+48B(+0.03%)

Details of bundle changes

Performance

Total duration: 1,120.11 ms ▼-315.39 ms(-22.0%) | Renders: 76 (+0) | Paint: 1,779.79 ms ▼-489.73 ms(-21.6%)

Test Duration Renders
Tabs mount (200 instances) 172.12 ms ▼-65.68 ms(-27.6%) 3 (+0)
Menu mount (300 instances) 97.05 ms ▼-38.21 ms(-28.2%) 1 (+0)
Slider mount (300 instances) 102.75 ms ▼-30.87 ms(-23.1%) 2 (+0)
Checkbox mount (500 instances) 56.40 ms ▼-24.67 ms(-30.4%) 1 (+0)
Menu open (500 items) 61.34 ms ▼-21.96 ms(-26.4%) 11 (+0)

…and 4 more (+6 within noise) — details


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

@netlify

netlify Bot commented Aug 10, 2026

Copy link
Copy Markdown

Deploy Preview for base-ui ready!

Name Link
🔨 Latest commit d2f2750
🔍 Latest deploy log https://app.netlify.com/projects/base-ui/deploys/6a7953cba9d6f60009549ca1
😎 Deploy Preview https://deploy-preview-5442--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.

Make the trigger registration callback stable and track the active
registration as a `(store, id, element)` triple. A downstream ref merger
that retains the callback it was first given now still reaches the
trigger's current store, and unregistering always targets the store the
element was registered in.

Migration between stores moves to a layout effect keyed on
`[store, id]`, since a stable ref no longer re-fires on those changes.
Skipping a register call that would re-register the same element under
the same store and id keeps map churn at its previous level.
`useTriggerRegistration` now returns a stable callback, so the ref no
longer re-fires when the swipe area's id changes. Re-register from a
layout effect keyed on `[store, id]`, matching `useTriggerDataForwarding`.

Without it the swipe area never registers on React 17, where `useId`
returns `undefined` on the first render and resolves in an effect, and a
changed `id` prop strands the registration under the old id.

Cover both cases, plus the ref-only caller shape in the
`useTriggerRegistration` unit tests.
`useTriggerRegistration` returns `void`, so the captured `cleanup` was
always `undefined`.
@atomiks

atomiks commented Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

@claude review low

@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown

PR review

Nothing here is merge-blocking. The core move — making the trigger-registration ref stable and tracking the live registration as a (store, id, element) triple, with migration driven by a [store, id]-keyed layout effect — is sound, and I verified the compensating effect exists at every call site of useTriggerRegistration (useTriggerDataForwarding, MenuSubmenuTrigger, DrawerSwipeArea). I checked ref-attach vs. layout-effect ordering (useMergedRefs attaches in array order; registerTrigger precedes the element ref in all four trigger components, and refs attach before the owning component's layout effect), unmount ordering, and the React 19 StrictMode remount cycle — registration lands correctly under each. One informational note below on a self-healing behavior that the new early return drops.

Bugs (1)

1. ℹ️ Early return drops the old code's self-healing re-registration

Location: packages/react/src/utils/popups/popupStoreUtils.ts:157

if (registration !== null) {
  if (
    registration.element === element &&
    registration.store === store &&
    registration.id === id
  ) {
    // Already registered where it belongs, so the caller's migration effect is free on mount.
    return;
  }

The early return trusts registrationRef as the source of truth for what's in store.context.triggerElements. The base implementation had no such shortcut: every call with a non-null element re-ran triggerElements.add(id, element) and re-synced triggerCount, so a registry entry that had been clobbered by someone else was restored on the next invocation.

The divergence is only observable when two concurrently mounted triggers share an id (a consumer passing the same id prop to two triggers of the same popup — plausible for detached triggers, where the id is the handle.open(triggerId) key). Trigger A registers (S, X, elA); trigger B mounts and overwrites map[X] = elB; B unmounts and deletes map[X] (A's getById guard correctly stops A from clobbering B, and B's delete guard passes because map[X] is still elB). A is now absent from the registry while its registrationRef still claims (S, X, elA), so any later registerTrigger(elA) early-returns and never restores it.

The id-collision precondition is a consumer mistake either way, and the old behavior only recovered by accident, so I don't think this needs to change. Flagging it because the invariant registrationRef.current encodes — "the registry entry I claim is still mine" — is now load-bearing where it previously wasn't.

Failure scenario: Two Popover.Triggers share an explicit id; the second unmounts; the first stays rendered but is gone from triggerElements, so triggerCount under-counts and handle.open(id) / useImplicitActiveTrigger can no longer resolve it.

Fix: Optional. If you want the old resilience back, re-verify before the shortcut, e.g. only early-return when store.context.triggerElements.getById(id) === element; otherwise fall through to the add. Alternatively, leave as-is and note in the JSDoc that the ref is authoritative.

Verdict

Approve — the stable-callback plus (store, id, element) triple is correct, every caller has the required [store, id] migration effect, and the only finding is informational.


🤖 Review generated with Claude Code · low effort · 22 turns · 7m9s · $1.81 · run

@atomiks
atomiks marked this pull request as ready for review August 10, 2026 04:57
@atomiks
atomiks merged commit cb0773d into mui:master Aug 10, 2026
23 checks passed
@atomiks
atomiks deleted the codex/fix-popover-stale-ref-registration branch August 11, 2026 03:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

component: popover Changes related to the popover component. type: regression A bug, but worse, it used to behave as expected.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[popover] Hover handoff between detached triggers sharing a handle silently drops the popup (regression in 1.7.0)

1 participant