You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
This PR trims popup work from the closed path so we only pay for interaction setup when a popup is actually open or in its mounted transition state.
Deferred root-side interaction wiring in the popup-heavy components
Removed the generic useRole + useInteractions hooks and replaced them explicit trigger/popup ARIA and mergeProps
Reduced extra popup-store synchronization work by moving floating-root setup into the store layer and syncing only the dynamic parts after commit. Renders down from 2 to 1 for Tooltip, Dialog, Popover, Preview Card. Menu is still 2.
Avoided idle close-completion logic in the shared popup utilities so closed popups that never mounted do not pay teardown costs. (Massive 85% teardown gain here.)
Kept Menu on the safer path for correctness (trying to match the others broke stuff too easily), but still reduced some closed-path root work and tightened trigger ownership behavior.
Added regression coverage for detached triggers, multiple triggers, first-open behavior, and popup ownership in the touched popup suites.
Why
The profiling showed two main costs:
closed popups were still doing too much interaction/setup work on initial mount
unmount was spending a lot of time cleaning up bookkeeping that never mattered for popups that were never opened
So the changes were aimed at:
making mount cheaper by deferring non-essential popup interactions (gated by open || mounted)
making unmount cheaper by skipping dead close-transition work
keeping detached-trigger, controlled, and multi-trigger behavior correct while doing that
File: packages/react/src/tooltip/popup/TooltipPopup.tsx
With useRole deleted, PopoverPopup (role: 'dialog') and MenuPopup (role: 'menu') got hardcoded replacements; TooltipPopup did not. The new test in TooltipRoot.test.tsx:38-55 asserts aria-describedby is gone from the trigger — so tooltips are now invisible to assistive tech. A11y regression with no loud failure.
C2 — TooltipTrigger onClick handler is either dead or double-fires
Guard fires only when the tooltip is not open, then calls setOpen(false) — either a no-op, or (since TooltipStore.setOpen has no same-state early-return) a duplicate onOpenChange fire. The new "calls onOpenChange once" test at TooltipRoot.test.tsx:891-914 likely passes by accident. Condition looks inverted.
C3 — Render-time mutation of store.state.floatingRootContext
File: packages/react/src/utils/popups/popupStoreUtils.ts:223 usePopupRootSync assigns store.state.floatingRootContext = … during render. Only the external-store branch calls notifyAll() inside a layout effect; non-external subscribers via store.useState('floatingRootContext') are not woken. Works today only because useSyncedFloatingRootContext returns a stable reference — any future change to that ref (e.g., Strict Mode double-render) silently desyncs subscribers. Use store.set(...) or useSyncedValue to make notification explicit.
C4 — openMethod never cleared on unmount; detached-trigger click never writes it
popupStoreUtils.ts:236-240 resets openMethod only when !open. If root unmounts while open === true, or a handle-based consumer has no mounted root, openMethod leaks to the next opener.
DialogTrigger.tsx:88 and PopoverTrigger.tsx:145 route handle-equipped triggers to rootTriggerProps, which relies on a mounted root running useOpenMethodTriggerProps. Handle-only consumers get no openMethod written. Silent focus-style / iOS touch regression.
Important
I1 — MenuRoot rewritten with zero new Menu tests
MenuRoot.tsx lost useRole, gained usePopupRootSync, gated dismiss/typeahead on open, yet MenuRoot.test.tsx and MenuRoot.detached-triggers.test.tsx are untouched. Gaps:
Dismiss/typeahead now enabled: open && !disabled — while the close-animation plays, Escape / outside-press / typeahead silently do nothing. Inconsistent with popupProps memo at MenuRoot.tsx:513 which uses open || mounted.
aria-haspopup: 'menu' and popup aria-labelledby: activeTriggerId moved from useRole to manual wiring — no tests assert these remain.
MenuRoot.tsx:103 changed open: defaultOpen → open: openProp ?? defaultOpen (SSR-visible first-paint change) with no test.
I2 — PreviewCard refactor entirely untested
No changes to preview-card test files. With useRole deleted, the aria-expanded/trigger ARIA path is gone and no replacement is visible in the PR. Also:
PreviewCardRoot.tsx:85-86 uses dismiss.reference ?? EMPTY_OBJECT — Dialog/Popover roots omit the fallback, creating an inconsistent contract (see I3).
PreviewCardStore.ts:122-134 conditionally calls useSyncedValue('floatingRootContext', …) only for external stores; no handle-based preview-card test exercises this.
I3 — inactiveTriggerProps = dismiss.trigger may be undefined in Dialog/Popover
useDialogRoot.ts:152-153 and the equivalent in PopoverRoot.tsx:129-150 don't apply the ?? EMPTY_OBJECT fallback. useDismiss returns {} when disabled, so current behavior may be safe, but the divergence from PreviewCardRoot signals unclear contract. Either remove the preview-card fallback or add it to Dialog/Popover.
I4 — Stale open read in Menu triggers
MenuTrigger.tsx:223-225 and MenuSubmenuTrigger.tsx:161-163 call useOpenMethodTriggerProps(store.select('open'), …). store.select doesn't subscribe, so the click handler sees a stale open. DialogTrigger/PopoverTrigger use store.useState('open') here — Menu should match.
I6 — Duplicated trigger-prop assembly across triggers
aria-haspopup / aria-expanded / aria-controls and the controlsPopup = open && (isOpenedByThisTrigger || activeTriggerId == null || store.context.triggerElements.size === 1) expression are copy-pasted across DialogTrigger, PopoverTrigger, MenuTrigger. AGENTS.md explicitly calls out avoiding duplication — extract into a shared hook next to useTriggerDataForwarding.
I7 — Inconsistent Root context shapes after refactor
M1 — MenuRoot.tsx:372 reads store.state.floatingRootContext directly; every other consumer uses store.useState(...). Diverges from the pattern.
M2 — One-frame stale triggerProps after popup opens in Dialog/Popover (*Interactions renders only when open || mounted, so the first render missing dismiss handlers). Minor — one lost frame of outside-press handling.
L1 — useOpenChangeComplete now gated mounted && !open && !preventUnmountingOnClose (popupStoreUtils.ts:186) — intentional but changes when the completion callback fires.
L2 — usePopupRootSync types eventDetails as any at popupStoreUtils.ts:214. Erodes type safety around reason codes. AGENTS.md discourages as any casts.
L3 — Dropped sentence in the iOS Safari comment at useOpenInteractionType.ts:11-12 — the "interactionType will be '' in that case" clause explained why the || fallback trips. Restore it.
L4 — Nested role: 'menuitem' for nested-menu triggers previously supplied by useRole — not restored. Verify MenuSubmenuTrigger still produces the right role.
L5 — useRole previously used getFloatingFocusElement(floatingElement)?.id || defaultFloatingId; the replacements use plain floatingId. Minor edge case when a child owns focus.
The reason will be displayed to describe this comment to others. Learn more.
Rename the variable to store? It would match the rest of the codebase.
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
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.
Changes
This PR trims popup work from the closed path so we only pay for interaction setup when a popup is actually open or in its mounted transition state.
useRole+useInteractionshooks and replaced them explicit trigger/popup ARIA andmergePropsMenuon the safer path for correctness (trying to match the others broke stuff too easily), but still reduced some closed-path root work and tightened trigger ownership behavior.Why
The profiling showed two main costs:
So the changes were aimed at:
open || mounted)