Skip to content

Commit 9140963

Browse files
fix(playground-ui): make popup portal container resolution null-safe (#17560)
## Summary Follow-up hardening to #17556. Base UI's `FloatingPortal` treats `container={null}` as *"not ready — render nothing"*, while `undefined` means *"portal to `document.body`"*. Our `PortalContainerContext` defaulted to `null`, so popups opened outside a `SideDialog` (the model & provider pickers live in the chat composer, which has no `PortalContainerProvider` ancestor) resolved their portal container to `null` and mounted to no DOM node — they opened in state but rendered nowhere. ## Change - `usePortalContainer(container?)` now resolves `explicit → provider → undefined` and **never returns `null`**; the "no container" sentinel is `undefined` to match Base UI's contract. - Dropped the scattered `?? undefined` band-aids in `Combobox`, `Select`, `Popover`, and `DropdownMenu` — they now just call `usePortalContainer(container)`. ## Tests - `portal-container.test.tsx` guards the resolver contract (never `null`; normalizes a `null` provider; explicit wins). - `combobox.test.tsx` asserts the popup portals into `document.body`. - Verified these fail under the old `null`-leaking behavior (combobox open/select tests go red) and pass with the fix. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## ELI5 Imagine a magical doorway that decides where popup menus (like dropdowns and options) should appear on the screen. This PR fixes a broken doorway that prevented popups from showing up when opened outside certain panels—now the doorway always works and shows popups in the right place (the main document area) as a fallback. ## Problem Base UI's FloatingPortal treats `container={null}` as "not ready — render nothing" while `undefined` means "portal to document.body". The PortalContainerContext was defaulting to `null`, which caused popups opened outside a SideDialog (such as model and provider pickers in the chat composer) to fail rendering because there was no valid DOM node to attach to. ## Solution The `usePortalContainer` hook was redesigned to: - Accept an optional `container` parameter that can explicitly override the default behavior - Resolve the final portal target with precedence: explicit argument → context provider value → `undefined` - Never return `null`; always return `undefined` as the "no container" sentinel to match Base UI's expectations The PortalContainerContext now treats `undefined` as the default (fall back to document.body) and normalizes any incoming `null` values to `undefined`. ## Changes Made - **portal-container.tsx**: Added `PortalContainer` type, updated `usePortalContainer` to accept an optional container override and exclude null from its return type, and normalized null values in the provider context - **Combobox, Select, Popover, and DropdownMenu**: Replaced manual fallback logic (`container ?? portalContainer ?? undefined`) with direct calls to `usePortalContainer(container)` - **portal-container.test.tsx**: Added comprehensive tests asserting the resolver contract (never returns null, normalizes null provider to undefined, explicit values override provider values) - **combobox.test.tsx**: Added test verifying popups mount into `document.body` when no portal container provider is present ## Testing Tests were added to verify the contract of the portal-container resolver and confirm that popups now mount correctly when opened outside a SideDialog. Tests were verified to fail under the previous null-leaking behavior and pass with the fix. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 5532141 commit 9140963

8 files changed

Lines changed: 108 additions & 32 deletions

File tree

.changeset/proud-gifts-appear.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@mastra/playground-ui': patch
3+
---
4+
5+
Fixed dropdown, combobox, select, and popover popups silently failing to render when opened outside a side panel (most visibly the model and provider pickers in the chat composer). The shared portal-container resolver now always falls back to the document body instead of leaking an unrenderable value.

packages/playground-ui/src/ds/components/Combobox/combobox.test.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,20 @@ describe('Combobox', () => {
4545
expect(screen.getByRole('option', { name: 'Google' })).toBeTruthy();
4646
});
4747

48+
it('portals the popup into document.body when there is no portal container provider', async () => {
49+
const { container } = renderCombobox();
50+
51+
fireEvent.click(screen.getByRole('combobox'));
52+
53+
// The regression: outside a SideDialog the portal container resolved to
54+
// `null`, which Base UI's FloatingPortal reads as "render nothing", so the
55+
// popup never mounted. It must land in document.body, outside the trigger's
56+
// own subtree.
57+
const option = await screen.findByRole('option', { name: 'OpenAI' });
58+
expect(document.body.contains(option)).toBe(true);
59+
expect(container.contains(option)).toBe(false);
60+
});
61+
4862
it('selects an item and fires onValueChange with the selected value', async () => {
4963
const onValueChange = vi.fn();
5064
renderCombobox({ onValueChange });

packages/playground-ui/src/ds/components/Combobox/combobox.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,7 @@ export function Combobox({
6060

6161
// Default to the nearest SideDialog/Drawer popup so the list stays
6262
// interactive inside a modal drawer; an explicit `container` still wins.
63-
const portalContainer = usePortalContainer();
64-
const resolvedContainer = container ?? portalContainer ?? undefined;
63+
const resolvedContainer = usePortalContainer(container);
6564

6665
const handleSelect = (item: ComboboxOption | null) => {
6766
if (item) {

packages/playground-ui/src/ds/components/DropdownMenu/dropdown-menu.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,9 @@ const DropdownMenuSubContent = React.forwardRef<HTMLDivElement, DropdownMenuSubC
7979
({ className, align = 'start', alignOffset = -4, side = 'right', sideOffset = 0, ...props }, ref) => {
8080
// Default to the nearest SideDialog/Drawer popup so the submenu stays
8181
// interactive inside a modal drawer.
82-
const portalContainer = usePortalContainer();
82+
const resolvedContainer = usePortalContainer();
8383
return (
84-
<MenuPrimitive.Portal container={portalContainer ?? undefined}>
84+
<MenuPrimitive.Portal container={resolvedContainer}>
8585
<MenuPrimitive.Positioner
8686
align={align}
8787
alignOffset={alignOffset}
@@ -111,8 +111,7 @@ const DropdownMenuContent = React.forwardRef<HTMLDivElement, DropdownMenuContent
111111
({ className, container, align = 'start', alignOffset = 0, side = 'bottom', sideOffset = 8, ...props }, ref) => {
112112
// Default to the nearest SideDialog/Drawer popup so the menu stays
113113
// interactive inside a modal drawer; an explicit `container` still wins.
114-
const portalContainer = usePortalContainer();
115-
const resolvedContainer = container ?? portalContainer ?? undefined;
114+
const resolvedContainer = usePortalContainer(container);
116115
return (
117116
<MenuPrimitive.Portal container={resolvedContainer}>
118117
<MenuPrimitive.Positioner

packages/playground-ui/src/ds/components/Popover/popover.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,10 @@ const PopoverContent = React.forwardRef<HTMLDivElement, PopoverContentProps>(
3434
const classNameString = typeof className === 'string' ? className : undefined;
3535
// Default to the nearest SideDialog/Drawer popup so the content stays
3636
// interactive inside a modal drawer; an explicit `container` still wins.
37-
const portalContainer = usePortalContainer();
38-
const resolvedContainer = container ?? portalContainer;
37+
const resolvedContainer = usePortalContainer(container);
3938

4039
return (
41-
<PopoverPrimitive.Portal container={resolvedContainer ?? undefined}>
40+
<PopoverPrimitive.Portal container={resolvedContainer}>
4241
<PopoverPrimitive.Positioner
4342
align={align}
4443
alignOffset={alignOffset}

packages/playground-ui/src/ds/components/Select/select.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -158,10 +158,9 @@ const SelectContent = React.forwardRef<HTMLDivElement, SelectContentProps>(
158158
) => {
159159
// Default to the nearest SideDialog/Drawer popup so the dropdown stays
160160
// interactive inside a modal drawer; an explicit `container` still wins.
161-
const portalContainer = usePortalContainer();
162-
const resolvedContainer = container ?? portalContainer;
161+
const resolvedContainer = usePortalContainer(container);
163162
return (
164-
<SelectPrimitive.Portal container={resolvedContainer ?? undefined}>
163+
<SelectPrimitive.Portal container={resolvedContainer}>
165164
<SelectPrimitive.Positioner
166165
className="z-50 outline-none"
167166
side={side}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// @vitest-environment jsdom
2+
import { renderHook } from '@testing-library/react';
3+
import * as React from 'react';
4+
import { describe, expect, it } from 'vitest';
5+
6+
import { PortalContainerProvider, usePortalContainer } from './portal-container';
7+
8+
/**
9+
* These guard the contract that prevented the model-picker regression:
10+
* `usePortalContainer` must resolve "no container" to `undefined`, never `null`.
11+
*
12+
* Base UI's `FloatingPortal` reads `container={undefined}` as "portal to
13+
* document.body" but `container={null}` as "not ready — render nothing". If the
14+
* resolver ever returns `null` again, dropdowns/combobox popups open in state but
15+
* mount to no DOM node, and these tests fail.
16+
*/
17+
describe('usePortalContainer', () => {
18+
it('returns undefined (NOT null) when there is no provider', () => {
19+
const { result } = renderHook(() => usePortalContainer());
20+
expect(result.current).toBeUndefined();
21+
expect(result.current).not.toBeNull();
22+
});
23+
24+
it('normalizes a null provider value to undefined', () => {
25+
const wrapper = ({ children }: { children: React.ReactNode }) => (
26+
<PortalContainerProvider container={null}>{children}</PortalContainerProvider>
27+
);
28+
const { result } = renderHook(() => usePortalContainer(), { wrapper });
29+
expect(result.current).toBeUndefined();
30+
});
31+
32+
it('returns the provider container when one is set', () => {
33+
const node = document.createElement('div');
34+
const wrapper = ({ children }: { children: React.ReactNode }) => (
35+
<PortalContainerProvider container={node}>{children}</PortalContainerProvider>
36+
);
37+
const { result } = renderHook(() => usePortalContainer(), { wrapper });
38+
expect(result.current).toBe(node);
39+
});
40+
41+
it('lets an explicit container win over the provider', () => {
42+
const provided = document.createElement('div');
43+
const explicit = document.createElement('section');
44+
const wrapper = ({ children }: { children: React.ReactNode }) => (
45+
<PortalContainerProvider container={provided}>{children}</PortalContainerProvider>
46+
);
47+
const { result } = renderHook(() => usePortalContainer(explicit), { wrapper });
48+
expect(result.current).toBe(explicit);
49+
});
50+
51+
it('coerces an explicit null container to undefined (falls back to provider)', () => {
52+
const provided = document.createElement('div');
53+
const wrapper = ({ children }: { children: React.ReactNode }) => (
54+
<PortalContainerProvider container={provided}>{children}</PortalContainerProvider>
55+
);
56+
const { result } = renderHook(() => usePortalContainer(null), { wrapper });
57+
expect(result.current).toBe(provided);
58+
});
59+
});
Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,22 @@
11
/* eslint-disable react-refresh/only-export-components */
22
import * as React from 'react';
33

4-
/**
5-
* Lets a modal container (e.g. `SideDialog`'s `Drawer`) advertise a DOM node
6-
* that nested portaled popups should render into instead of `document.body`.
7-
*
8-
* Why this exists: Base UI's modal `Drawer` wraps its contents in a
9-
* `FloatingFocusManager` with `modal`, which traps focus/interaction inside the
10-
* drawer's floating element. A popup portaled to `document.body` (the default
11-
* for `Select`, `Popover`, `DropdownMenu`, `Combobox`) lands *outside* that
12-
* region and becomes unclickable. Portaling it into a node inside the drawer
13-
* keeps it within the modal region, so it stays interactive.
14-
*
15-
* Portaled components default their portal `container` to this value, so any
16-
* dropdown placed inside a `SideDialog` works without per-call wiring. A `null`
17-
* value (the default, outside any provider) means "fall back to document.body".
18-
*/
19-
const PortalContainerContext = React.createContext<HTMLElement | null>(null);
4+
/** Anything Base UI's `*.Portal` `container` accepts. */
5+
export type PortalContainer =
6+
| HTMLElement
7+
| ShadowRoot
8+
| React.RefObject<HTMLElement | ShadowRoot | null>
9+
| null
10+
| undefined;
11+
12+
// Why: modal SideDialog/Drawer traps focus+clicks inside its region. Popups portal to
13+
// document.body by default → land outside the trap → unclickable. SideDialog publishes a
14+
// node inside the trap; popups portal there instead.
15+
//
16+
// Sentinel is `undefined`, never `null`: Base UI reads container={undefined} as "portal to
17+
// body", but container={null} as "not ready, render nothing" — a leaked null opens the popup
18+
// in state but mounts it nowhere (the model-picker bug).
19+
const PortalContainerContext = React.createContext<HTMLElement | undefined>(undefined);
2020

2121
export function PortalContainerProvider({
2222
container,
@@ -25,10 +25,12 @@ export function PortalContainerProvider({
2525
container: HTMLElement | null;
2626
children: React.ReactNode;
2727
}) {
28-
return <PortalContainerContext.Provider value={container}>{children}</PortalContainerContext.Provider>;
28+
// Accept null for callers; normalize so null never reaches a portal.
29+
return <PortalContainerContext.Provider value={container ?? undefined}>{children}</PortalContainerContext.Provider>;
2930
}
3031

31-
/** Nearest portal container node, or `null` to fall back to `document.body`. */
32-
export function usePortalContainer(): HTMLElement | null {
33-
return React.useContext(PortalContainerContext);
32+
/** Resolve a popup's portal target: explicit `container` → provider → `undefined` (body). Never null. */
33+
export function usePortalContainer(container?: PortalContainer): Exclude<PortalContainer, null> {
34+
const fromContext = React.useContext(PortalContainerContext);
35+
return container ?? fromContext ?? undefined;
3436
}

0 commit comments

Comments
 (0)