Support ComboboxProvider inside Menu
Motivation
Building a searchable menu currently requires ComboboxProvider to wrap MenuProvider, even though the Combobox input and list conceptually belong to the Menu popup. This makes reusable Menu abstractions harder to compose because adding search changes the provider hierarchy around the trigger and the entire Menu rather than only the popup content.
The provider order is currently required because useMenuStore reads an ancestor Combobox provider while creating the Menu store. Core then merges almost the entire Combobox store into Menu. With the desired nesting, Menu and Combobox instead create unrelated stores, so MenuButton opens only Menu, ComboboxList remains tied to its own closed state, and focus and popup semantics are not coordinated.
Supporting the inner provider should also give us a migration path away from this Menu-specific coupling. The new nesting should become the only canonical composition API. The implicit outer-provider lookup, the combobox option on Menu stores, and the public menu.combobox relationship should be deprecated and removed in a future breaking release rather than kept as parallel permanent APIs.
The working prototype shows that the behaviors covered by the current interaction suite can be implemented without merging Composite or Collection state. They use a narrow generic relationship: a shared disclosure lifecycle, delegation to the embedded composite for focus, and enough semantic metadata for Menu to expose the correct popup behavior. This ownership model should be validated against the remaining nested-menu, portal, animation, SSR, and browser cases before it becomes the removal architecture.
Usage example
<MenuProvider>
<MenuButton />
<Menu>
<ComboboxProvider>
<Combobox />
<ComboboxList>
<ComboboxItem value="Apple" />
<ComboboxItem value="Orange" />
</ComboboxList>
</ComboboxProvider>
</Menu>
</MenuProvider>The composition should be inferred from the mounted child without adding a public Menu option that describes the descendant implementation.
Decisions
These were settled during design review. They resolve every item this issue previously listed as needing definition, and the rationale is recorded here so implementation does not have to reconstruct it.
| # | Question | Decision |
|---|---|---|
| 1 | Which nested shape is supported | ComboboxProvider inside <Menu>, as the usage example above shows |
| 2 | Opt-out for an unrelated Combobox in Menu content | <ComboboxProvider menu={null}>, mirroring the existing <MenuProvider combobox={null}> |
| 3 | More than one Combobox in one Menu | First registration wins, later ones stay independent |
| 4 | How much state stays shared | The Disclosure lifecycle only |
| 5 | Explicit versus nested precedence | Explicit wins, and a nested child holding the same store is a no-op |
| 6 | Pre-mount aria-haspopup |
Accepted, with the existing aria-haspopup="dialog" override documented |
| 7 | Two providers or a first-class searchable Menu | Two providers |
| 8 | Deprecation strength and timing | Hard deprecation, in the same release as the nested API |
| 9 | Tab's context lookup | Left to #6826, with the lookup rule written down below |
| 10 | Combobox state when the popup unmounts | State the ownership rule and document the reset |
Registration rules
<ComboboxProvider menu={null}>suppresses both the Disclosure link and the registration. Passing a Menu store instead ofnullis the explicit form.- The first child to register wins. "First" means first to register and still mounted, not first in source order, because conditional rendering and portals make source order unreliable.
- A registered child that unmounts must release its claim so the next mounted candidate can take over. Without this, a conditionally rendered search field would leave Menu permanently stripped of its own typeahead.
- An explicit relationship beats a nested one. A nested child holding the same store is a no-op. A nested child holding a different store while an explicit relationship exists stays independent, by the first-registration rule.
- State that must outlive one opening belongs on the Menu store or in consumer state. The Combobox store is scoped to one opening of the popup.
How a store finds another store
A store never adopts something it found by looking downward. It either receives the other store from an ancestor provider, or is told by a descendant that registered itself. That gives three lookups with three jobs:
useXProviderContext()sees a provider above the caller, but not the component the caller is rendered inside. Use it when a widget is wrapped by another widget's provider.useXContext()sees the nearest store anywhere above, including inside a popup. Use it for parent and child relationships within one family, and for the Disclosure link.- Scoped registration sees nothing, because the child announces itself. Use it when a widget living inside another widget's popup changes what that popup is.
Two mechanisms, two timings
Linking the two stores and telling Menu what it now contains are separate steps that happen at different moments.
Discovery is synchronous. Inside <Menu>, the scoped Menu context already returns the Menu store, so ComboboxProvider can take it as its disclosure at store-creation time. The lifecycle is therefore linked from the child's first render, and nothing about opening or closing is late.
Registration happens after commit. Only the popup role, the Composite and typeahead switch, and focus delegation settle late.
Requirements
- The nested provider example above should work without lifting a Combobox store, passing a Combobox-specific option to
MenuProvider, or wrappingMenuProviderinComboboxProvider. - Menu should continue to own the popup, positioning, dismissal, focus restoration, and parent-menu hierarchy. Combobox should own filtering, input value, item registration, active item, navigation, virtual focus, and its Composite defaults. Those Composite, Collection, and positioning states should not be synchronized between the stores.
- The only bidirectionally synchronized state currently demonstrated as necessary is the generic Disclosure lifecycle for one logical popup, including
open, effectivemounted, and animation state. Menu opening or closing must update Combobox, and Combobox selection or hiding must close Menu and run Combobox reset-on-hide behavior. - Menu should interact with the child through a component-neutral embedded-composite contract. It needs to delegate focus to the container, first item, or last item; reset the child's active item when appropriate; learn the popup role; and access the child content element for nested role and tab-order logic. It should not read the child's item array or active state directly.
- A nested provider should compose only with the nearest scoped Menu popup, not merely any
MenuProviderabove it, and it should unregister safely. Composition should use a scoped runtime registration rather than static React child inspection. Multiple-child behavior, the opt-out for unrelated Comboboxes in Menu content, and precedence for explicit relationships are settled in Decisions above. - Click, Enter, Space, ArrowDown, and ArrowUp should open the searchable Menu and preserve current initial-focus behavior for the input and first or last option.
- Filtering, auto-selection, Backspace, keyboard movement, hover movement, reset-on-hide, reopening, outside interaction, focus restoration, and nested submenu behavior should match the current searchable Menu examples. Escape should retain Menu's current
hideAll()hierarchy behavior. Selecting a Combobox item should close the linked Menu as it does today, while expandable items should continue to keep their parent Menu open. - Menu and Combobox must keep independent
arrowElement,anchorElement,contentElement,popoverElement, anddisclosureElementreferences. The implementation must preserve conditional filtering and liveComboboxItemregistration. - The searchable popup should retain Combobox defaults for
virtualFocus,compositeElementInFocusOrder,focusLoop, andfocusWrap. Explicit options should apply to the component that owns them instead of being resolved through provider order or already-materialized defaults on another store. - Once the Combobox child registers, Menu should expose dialog popup semantics, disable its own Composite and typeahead behavior, and preserve
aria-activedescendanton the Combobox input. - This feature should not add a public Menu option solely to describe a descendant that has not mounted. With SSR or
Menu unmountOnHide, the trigger may use Menu's existing fallback until the content mounts, as it already does when a conditionally mounted Menu receives a differentrole. Consumers that require stable pre-mount semantics can use the existing<MenuButton aria-haspopup="dialog">override. - Child registration during an opening commit should settle Menu role, Composite/typeahead mode, and focus delegation before paint and before Dialog autofocus. The first-open path must be verified with
unmountOnHiderather than assuming a later rerender is early enough. - Because the nested provider lives inside the popup,
Menu unmountOnHidereplaces the Combobox store on every open. State that must outlive one opening belongs on the Menu store or in consumer state, and this should be documented rather than compensated for. Menu must not cache or restore the child's state. - During the transition, the legacy outer-provider form and explicit
useMenuStore({ combobox })or<MenuProvider combobox={combobox}>form must preserve their documented observable contract, includingmenu.comboboxidentity, bidirectional shared state, initial default precedence, and conflict behavior. Menu behavior should nevertheless run through the new generic engine. Any temporary state mirror should be isolated in the compatibility adapter and should not be consumed by Menu components. - The legacy
comboboxMenu option/property should receive@deprecatedannotations with migration guidance appropriate to each symbol, and implicit ancestor discovery should emit a development-only warning after commit with the equivalent inner-provider example. Both ship in the same release as the nested API rather than a later one, so the nested API and the low-level Core replacement must be documented and stable within that release. Consumers that need direct Combobox access can retain their own reference rather than readingmenu.combobox. Warnings should be deliberate under React Strict Mode and should not fire for the new composition. - A later breaking release, after a published deprecation window, should remove the ancestor Combobox context lookup, the Core and React
MenuStoreOptions.comboboxoption, theMenuStore.comboboxproperty, the broad Combobox-to-Menu synchronization, and all Menu branches that directly inspect a Combobox store. - Coverage should also pin the settled rules:
menu={null}leaves an unrelated Combobox and its Menu unchanged, a registered child that unmounts returns Menu to its own Composite and typeahead behavior, and the reset surface stays deliberate so nothing joinsselectedValuesilently. - The existing Menu and Combobox interaction and browser suites should exercise the new nested form and the compatibility adapter during the deprecation window. Legacy fixtures should continue asserting the current stable pre-open
aria-haspopup="dialog"contract while the adapter exists. The nestedunmountOnHidecase should assert that the opened popup settles to dialog semantics before paint, focus and parent-menu behavior remain correct, and the existing explicitaria-haspopupoverride remains stable. After removal, the legacy fixtures and adapter should be deleted rather than preserved as a second test matrix.
Workaround
Until the new API ships, the current example wraps the Menu provider:
<ComboboxProvider resetValueOnHide>
<MenuProvider>
<MenuButton />
<Menu>
<Combobox />
<ComboboxList>
<ComboboxItem value="Apple" />
<ComboboxItem value="Orange" />
</ComboboxList>
</Menu>
</MenuProvider>
</ComboboxProvider>If the physical placement of ComboboxProvider inside Menu is important, the store can currently be lifted and connected explicitly:
const combobox = useComboboxStore({ resetValueOnHide: true });
<MenuProvider combobox={combobox}>
<MenuButton />
<Menu>
<ComboboxProvider store={combobox}>
<Combobox />
<ComboboxList>
<ComboboxItem value="Apple" />
<ComboboxItem value="Orange" />
</ComboboxList>
</ComboboxProvider>
</Menu>
</MenuProvider>Both snippets rely on the legacy Menu-specific Combobox relationship. They are migration sources, not APIs that should survive the final removal.
Possible implementations
State ownership
The current merge synchronizes 23 overlapping state keys even though the stores expose separate methods. They fall into three groups:
- Disclosure:
open,mounted,animated, andanimating. - Composite and Collection:
activeId,baseElement,compositeElement,compositeElementInFocusOrder,focusLoop,focusShift,focusWrap,id,includesBaseElement,items,moves,orientation,renderedItems,rtl, andvirtualFocus. - Positioning:
placement,currentPlacement,rendered, andunstable_placing.
Combobox-specific values such as inputValue, value, activeValue, selectedValue, resetValueOnHide, and selectOnMove are already not shared. The five popup element references are deliberately omitted from the current merge.
Only three Menu modules directly branch on the concrete relationship today. MenuList disables Menu Composite, role, and typeahead behavior, Menu uses Combobox content for nested tab-order semantics, and MenuButton uses it for nested roles and the stable pre-mount dialog fallback. The broad ownership direction was established in #2783 and #2795, with important focus and nested-composite behavior in #2582 and #3124.
The prototype demonstrates that the Disclosure group can provide the common source of truth for the covered behaviors. The existing disclosure store relationship already synchronizes the lifecycle while keeping the two content and disclosure elements independent. Ariakit uses the same public relationship for a Combobox inside a Dialog command menu:
const menu = useMenuStore();
const combobox = useComboboxStore({
disclosure: menu,
resetValueOnHide: true,
});In a store experiment, menu.show() opened both stores, hiding either store closed both, and hiding reset the Combobox input. Combobox items, activeId, and focus options remained independent from Menu, while Menu positioning remained independent from Combobox.
The remaining relationship can be a small generic registration protocol rather than a shared Composite parent. Conceptually:
interface EmbeddedComposite {
getContentElement(): HTMLElement | null;
focus(target: "container" | "first" | "last"): void;
resetActive(): void;
}This is an illustrative internal contract, not necessarily a new public API. Menu can use the registration to delegate trigger and initial focus, disable its own Composite and typeahead behavior, and resolve nested content reactively. Once the content exists, MenuButton can derive the popup role through the existing element-based logic. Combobox can otherwise process input, filtering, item movement, hover, selection, and aria-activedescendant entirely through its own store. A read-only host-positioned signal may also be needed so Combobox auto-selection does not run while the outer Menu is still being placed, but the positioning state should stay Menu-owned.
The child should register in a safe layout phase. For a normally hidden Menu, the descendant exists while closed and can register before ordinary interaction. With unmountOnHide, it registers during the first opening commit; Menu must make the registered controller available to the focus path before Dialog's passive autofocus runs. Registering by mutating the parent during descendant render would be unsafe under concurrent or aborted renders.
I prototyped this split with public APIs plus a small focus adapter: independent Menu and Combobox stores, disclosure: menu, Menu composite={false} typeahead={false}, a dialog popup role, and delegation of Menu's first/last initial-focus intent to Combobox. The nested variant passed all 9 existing menu-combobox-interactions cases plus a popup-role assertion. This is stronger evidence for Disclosure synchronization plus focus delegation than for reversing the current Composite and Collection merge.
By comparison, simply moving the provider inside with no relationship failed all 9 nested cases. A reverse merge based on omit(menu, popupElementKeys) passed only 1 of 9 because it initially inherited already-materialized Menu defaults. After forcing the Combobox defaults and making Menu react to the child, the nested variant passed 6 of 9. Three filtering and ordering cases still failed because the generic derived store did not preserve the Collection private registration lookup.
Verified independently
Six disposable probes were run against the package sources at 425f701 in happy-dom, then removed. They corroborate the scoreboard above and pin the behaviors the decisions depend on.
- Placing
ComboboxProviderinsideMenuProvider, or inside<Menu>, fails silently today. The popup keepsrole="menu", focus lands on the Menu container rather than the input, and typing never reaches the input. No error or warning is produced. useMenuProviderContext()returnsundefinedinside<Menu>, whileuseMenuContext()returns the Menu store. This is why the Disclosure link can use the plain lookup while the semantic link needs registration.- A reverse merge loses the Combobox defaults for
virtualFocusandfocusLoop, because every default increateComboboxStorereads the merged store before falling back to a literal. This reproduces the 1 of 9 result above from a different direction. createComboboxStore({ disclosure: menu })shares the lifecycle in both directions and nothing else. Showing Menu opens Combobox, hiding either closes both, andresetValueOnHidestill fires, whileactiveId,placement,virtualFocus,focusLoopand both content elements stay independent. Menu gains no reference to the Combobox, which confirms registration is a separate requirement rather than a consequence of the link.- With
Menu unmountOnHide, a provider inside the popup is destroyed and recreated on each open. WithoutunmountOnHidea single store persists.
Source: ariakit/ariakit