[combobox][select] Decouple auto-unmount from `actionsRef` presence
- 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
Assessment
This issue has not been assessed yet.