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', });