[popups] Improve performance when detached triggers are used
Refactored the FloatingRootContext to be based on the Store, allowing for selective subscription to changes in its state.
Previously, FloatingRootContext was recreated whenever the open state changed. As triggers were subscribed to it, they all rerendered whenever an associated popup opened or closed.
This improvement applies to all "detachable" components: Menu, Tooltip, Popover, and (Alert)Dialog.
Additionally, in the Menu, due to unnecessary subscriptions, triggers also rerendered when the active item index within a menu was updated (so hovering over menu items caused re-rendering of all related menu triggers).
Before (Menu example):
https://github.com/user-attachments/assets/c25b37a2-c102-482b-9641-3f3ab7d1938b
After:
https://github.com/user-attachments/assets/97b0eb0f-6c5b-4cf2-af7f-109c7e3421d6
Technical notes
The biggest change is the rewrite of FloatingRootContext. It now acts as a backing data store that useFloating uses. Instead of being recreated on every render or dependency change, it keeps a stable reference for the lifetime of a component. A store implementation allows hooks to selectively depend on pieces of state. Also, thanks to Store's select method, interaction hooks can use non-reactive values in event handlers and effects.
The FloatingRootStore is created based on the component store with the new hook: UseSyncedFloatingRootContext. It makes sure the pieces of state in these two stores are kept in sync (see also point 1 in Next steps below).
To simplify types, I removed the type parameter from useFloating (previously RT extends ReferenceType). We never made use of it, so I used ReferenceType directly where RT was used before.
I moved most of the logic out of triggers, reducing the initial number of renders to 2 (the minimum possible with composable API). I also unified their implementation, extracting common parts.
Next steps
-
We can further improve the implementation by deduplicating pieces of state such as
open,floatingElement, etc. from both stores (main component store and FloatingRootStore). We can consider making the Floating UI primitives to work with our PopupStore (or the other way around - use FloatingRootStore to keep component data). -
Make triggers lazy - skip all effects until the rendered element is interacted with. This requires further investigation.
pnpm add https://pkg.pr.new/mui/base-ui/@base-ui-components/react@3277
pnpm add https://pkg.pr.new/mui/base-ui/@base-ui-components/utils@3277
commit: 8ede505
Bundle size report
| Bundle | Parsed size | Gzip size |
|---|---|---|
| @base-ui-components/react | 🔺+1.27KB(+0.31%) | 🔺+314B(+0.24%) |
Check out the code infra dashboard for more information about this PR.
Deploy Preview for base-ui ready!
| Name | Link |
|---|---|
| Latest commit | 8ede505ab884d3784b2008f9d027e7e35547f75f |
| Latest deploy log | https://app.netlify.com/projects/base-ui/deploys/692847083b92140008d5be97 |
| Deploy Preview | https://deploy-preview-3277--base-ui.netlify.app |
| Preview on mobile | Toggle QR Code...Use your smartphone camera to open QR code link. |
To edit notification comments on pull requests, go to your Netlify project configuration.
- The
[data-popup-open]state (on both Menu/Tooltip) seems to be incorrectly linked tomountednow instead ofopenso it lingers as popups close - The
[data-ending-style]transition of an instant tooltip issue returned (#2962)
Bugs:
- Tooltip: Pressing a trigger with the pointer before the tooltip shows no longer cancels it from showing
- Combobox: Pressing a trigger to toggle the popup (closed once it's open) no longer works when the input is outside the popup
Let's add regression tests for the issues encountered here as well
If I'm not mistaken, this seems to have introduced a regression: https://github.com/mui/base-ui/issues/3363