mui / mui/base-ui

[combobox][select] Decouple auto-unmount from `actionsRef` presence

Open
#5,587 0 comments 0 reactions 1 assignee Claimed by @michaldudak View on GitHub
component: autocomplete component: combobox component: select type: bug
Dominant language
TypeScript
Stars
10.9k
Forks
543
Avg merge
1d 20h
Merged PRs (30d)
101

Description

## Summary

`Combobox.Root`, `Autocomplete.Root` and `Select.Root` disable the built-in unmount-on-close whenever an `actionsRef` is passed at all:

```ts
// packages/react/src/combobox/root/AriaCombobox.tsx
useOpenChangeComplete({
enabled: !props.actionsRef,
// ...
onComplete() {
if (!open) {
handleUnmount();
}
},
});
```

`Select.Root` does the same in `SelectRoot.tsx`.

That made sense while `unmount` was the only action: passing `actionsRef` *was* the statement "I will manage unmounting". It no longer holds now that `actionsRef` also carries `highlightItem`, added in #5586 for #5146. A consumer who only wants to bind a keyboard shortcut silently inherits a lifecycle obligation they never asked for.

## Every other popup component already does this correctly

This is not a library-wide contract — it is a legacy pattern surviving in two places. `Menu`, `Popover`, `Dialog`, `Tooltip` and `PreviewCard` all use `useOpenStateTransitions` (`packages/react/src/utils/popups/popupStoreUtils.ts`), where auto-unmount is gated on a per-close-cycle flag rather than on the presence of a ref:

```ts
const forceUnmount = useStableCallback(() => { /* ... */ });

useOpenChangeComplete({
enabled: mounted && !open && !syncedPreventUnmountingOnClose,
// ...
});

return { forceUnmount, transitionStatus };
```

There, `actionsRef.unmount` is purely additive — you get it for free, and automatic unmounting keeps working unless you explicitly opt out for that particular close via `details.preventUnmountOnClose()`. Passing `actionsRef` to a `Menu.Root` to call `close()` costs you nothing.

Combobox, Autocomplete and Select are simply the components that have not been migrated to that model yet.

## Why it is more than cosmetic

`handleUnmount()` is also what fires `onOpenChangeComplete(false)`. So passing `actionsRef` suppresses the very signal a consumer would use to know *when* to call `unmount()`. They have to fall back to `onOpenChange(false)` plus their own timing, which is exactly the bookkeeping `onOpenChangeComplete` exists to avoid.

Left unhandled, the popup stays mounted and visible after close (no `hidden`, no `display: none`), still exposed as `role="listbox"`, and highlight/index cleanup is skipped.

## Reproduction

`packages/react/src/autocomplete/root/AutocompleteRoot.test.tsx` (added in #5586) contains a test pinning the current behaviour — `leaves the popup mounted until unmount() is called when actionsRef is attached`. It asserts the listbox survives Escape and that `onOpenChangeComplete` is never called with `false`.

## Suggested fix

Migrate `AriaCombobox` and `SelectRoot` to the same `preventUnmountingOnClose` model the other popups use, so that `actionsRef` carries no lifecycle meaning and `unmount()` becomes an escape hatch rather than an obligation. That also removes the `onOpenChangeComplete(false)` suppression as a side effect.

## Note

This is a deliberate behaviour change: `SelectRoot.test.tsx` ("unmounts the select when the `unmount` method is called") asserts the popup is still present after close, so it would need updating alongside.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.