diff --git a/packages/react/src/combobox/positioner/ComboboxPositioner.test.tsx b/packages/react/src/combobox/positioner/ComboboxPositioner.test.tsx
index 4648b2bf42f..2b2d71bf071 100644
--- a/packages/react/src/combobox/positioner/ComboboxPositioner.test.tsx
+++ b/packages/react/src/combobox/positioner/ComboboxPositioner.test.tsx
@@ -1,5 +1,6 @@
-import { expect } from 'vitest';
+import { expect, vi } from 'vitest';
import * as React from 'react';
+import * as ReactDOMClient from 'react-dom/client';
import { waitFor } from '@mui/internal-test-utils';
import { Combobox } from '@base-ui/react/combobox';
import { createRenderer, describeConformance, isJSDOM } from '#test-utils';
@@ -7,6 +8,61 @@ import { createRenderer, describeConformance, isJSDOM } from '#test-utils';
describe('', () => {
const { render } = createRenderer();
+ it('should not lock body scroll when controlled value={[]} triggers a re-render', async () => {
+ // Render outside of act() to match real browser behavior where
+ // the initial render and the useEffect re-render are separate commits.
+ // Using act() batches them, hiding the bug.
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ document.body.removeAttribute('style');
+ document.documentElement.removeAttribute('style');
+
+ function Test() {
+ const [, forceRender] = React.useState(0);
+ React.useEffect(() => {
+ forceRender(1);
+ }, []);
+
+ return (
+
+
+
+
+
+
+ );
+ }
+
+ const root = ReactDOMClient.createRoot(container);
+ root.render();
+
+ // Wait for mount + useEffect re-render + setTimeout(0) in ScrollLocker.acquire
+ await new Promise((resolve) => {
+ setTimeout(resolve, 500);
+ });
+
+ // Bug: the re-render causes forceMount, mounting the Positioner.
+ // The Positioner's `open` is `undefined` (not `false`), so
+ // `open && modal` evaluates to `undefined`, which triggers
+ // useScrollLock's default parameter `enabled = true`.
+ const bodyOverflowX = document.body.style.overflowX;
+ const bodyOverflowY = document.body.style.overflowY;
+ const htmlOverflowX = document.documentElement.style.overflowX;
+ const htmlOverflowY = document.documentElement.style.overflowY;
+
+ root.unmount();
+ container.remove();
+ document.body.removeAttribute('style');
+ document.documentElement.removeAttribute('style');
+ consoleSpy.mockRestore();
+
+ expect(bodyOverflowX).not.toBe('hidden');
+ expect(bodyOverflowY).not.toBe('hidden');
+ expect(htmlOverflowX).not.toBe('hidden');
+ expect(htmlOverflowY).not.toBe('hidden');
+ });
+
describeConformance(, () => ({
refInstanceof: window.HTMLDivElement,
render(node) {
diff --git a/packages/react/src/combobox/root/AriaCombobox.tsx b/packages/react/src/combobox/root/AriaCombobox.tsx
index 14cb4ef80fe..fd040bbb83f 100644
--- a/packages/react/src/combobox/root/AriaCombobox.tsx
+++ b/packages/react/src/combobox/root/AriaCombobox.tsx
@@ -84,6 +84,8 @@ export function AriaCombobox(
onSelectedValueChange,
defaultInputValue: defaultInputValueProp,
inputValue: inputValueProp,
+ open: openProp,
+ defaultOpen = false,
selectionMode = 'none',
onItemHighlighted: onItemHighlightedProp,
name: nameProp,
@@ -219,8 +221,8 @@ export function AriaCombobox(
});
const [open, setOpenUnwrapped] = useControlled({
- controlled: props.open,
- default: props.defaultOpen,
+ controlled: openProp,
+ default: defaultOpen,
name: 'Combobox',
state: 'open',
});