[combobox] Prevent scroll lock when controlled value triggers re-render#4507
Conversation
commit: |
Bundle size report
Check out the code infra dashboard for more information about this PR. |
✅ Deploy Preview for base-ui ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
When a Combobox with controlled `multiple value={[]}` re-renders after
mount (e.g. from a useEffect), the Positioner's useScrollLock receives
`undefined` instead of `false`, triggering an unwanted body scroll lock.
The test renders outside of act() because act() batches the initial
render and the useEffect re-render into a single commit, hiding the
bug. In real applications (e.g. Next.js with SSR + hydration), these
are separate commits.
Reproduction: mui#4506
The ComboboxPositioner passes `open && modal && openMethod !== 'touch'`
to useScrollLock. When `open` is `undefined` (which happens when
controlled value={[]} triggers a forceMount before the store
initializes `open`), the && chain evaluates to `undefined`.
useScrollLock has a default parameter `enabled = true`, so passing
`undefined` triggers the default and erroneously locks body scroll.
Coercing with `!!` ensures `undefined` becomes `false`.
Fixes mui#4506
1352a39 to
83cd1a1
Compare
atomiks
left a comment
There was a problem hiding this comment.
Codex Review (GPT-5.4)
This PR adds a solid regression test for the reported scroll-lock symptom, but I don’t think the one-line !!(...) change addresses the actual root cause. The main risk is that Combobox still allows its internal open state to start as runtime undefined even though the rest of the component tree treats it as a boolean.
1. Bugs / Issues
1. 🟡 open is still initialized inconsistently with its declared boolean contract (non-blocking)
In packages/react/src/combobox/root/AriaCombobox.tsx, open is created from useControlled({ controlled: props.open, default: props.defaultOpen, ... }). Because defaultOpen is optional and useControlled returns value as T, the first uncontrolled render can still produce runtime undefined here even though the combobox store and downstream consumers treat open as a real boolean. This point is based on code inspection rather than a separate repro beyond the new regression test.
From packages/react/src/combobox/root/AriaCombobox.tsx:
const [open, setOpenUnwrapped] = useControlled({
controlled: props.open,
default: props.defaultOpen,
name: 'Combobox',
state: 'open',
});From packages/utils/src/useControlled.ts:
const [valueState, setValue] = React.useState(defaultProp);
const value = isControlled ? controlled : valueState;
return [value as T, setValueIfUncontrolled];This matters because useScrollLock(!!(open && modal && openMethod !== 'touch'), ...) only hardens one consumer. The underlying runtime/type mismatch remains in the combobox state itself, so other code paths that read open as a boolean still depend on an invariant that is not actually guaranteed from the first render.
Fix: Initialize the uncontrolled open state at the source, for example by defaulting defaultOpen to false in the prop destructuring or by passing default: props.defaultOpen ?? false to useControlled.
2. Root Cause & Patch Assessment
The new test is useful and pnpm test:jsdom ComboboxPositioner --no-watch passes, so the reported scroll-lock regression does appear covered. I still think the patch is aimed at the symptom rather than the cause: the description says the positioner condition becomes undefined, but the more important issue is that open itself is allowed to violate its documented/store-level boolean contract before it ever reaches useScrollLock.
Merge Confidence Score
Overall merge confidence is 3/5. I’m moderately confident the PR fixes the reported scroll-lock path, but I would prefer to address issue 1 before merging so the open state is consistent at the source instead of only being coerced at this one callsite.
Summary
Fixes #4506
When a controlled
Comboboxwithmultiple value={[]}(or any inline array) re-renders after mount (e.g. from auseEffect),document.bodygetsoverflow: hidden, preventing page scrolling.Root cause: The re-render passes a new
[]reference, triggeringforceMount()(since noitemsprop is provided), which mounts the Portal/Positioner. At that point, the store'sopenis stillundefined(notfalse). The Positioner passesopen && modal && openMethod !== 'touch'touseScrollLock(), which evaluates toundefined. BecauseuseScrollLockhas a default parameterenabled = true,undefinedis treated astrue, and the scroll lock engages.Fix: Coerce the expression to boolean with
!!()soundefinedbecomesfalse.Minimal reproduction
Steps:
npx create-next-app,npm install @base-ui/react, paste above asapp/page.tsx,npm run dev. Page cannot scroll.Test plan
VITEST_ENV=jsdom npx vitest run packages/react/src/combobox/positioner/ComboboxPositioner.test.tsx