Skip to content

[combobox] Prevent scroll lock when controlled value triggers re-render#4507

Merged
atomiks merged 4 commits intomui:masterfrom
ryanrhee:fix/combobox-scroll-lock-undefined
Apr 6, 2026
Merged

[combobox] Prevent scroll lock when controlled value triggers re-render#4507
atomiks merged 4 commits intomui:masterfrom
ryanrhee:fix/combobox-scroll-lock-undefined

Conversation

@ryanrhee
Copy link
Copy Markdown
Contributor

@ryanrhee ryanrhee commented Apr 2, 2026

Summary

Fixes #4506

When a controlled Combobox with multiple value={[]} (or any inline array) re-renders after mount (e.g. from a useEffect), document.body gets overflow: hidden, preventing page scrolling.

Root cause: The re-render passes a new [] reference, triggering forceMount() (since no items prop is provided), which mounts the Portal/Positioner. At that point, the store's open is still undefined (not false). The Positioner passes open && modal && openMethod !== 'touch' to useScrollLock(), which evaluates to undefined. Because useScrollLock has a default parameter enabled = true, undefined is treated as true, and the scroll lock engages.

Fix: Coerce the expression to boolean with !!() so undefined becomes false.

Minimal reproduction

"use client";
import { useState, useEffect } from "react";
import { Combobox } from "@base-ui/react/combobox";

export default function Home() {
  const [, forceRender] = useState(0);
  useEffect(() => { forceRender(1); }, []);

  return (
    <div style={{ height: "200vh" }}>
      <Combobox.Root multiple value={[]}>
        <Combobox.Input />
        <Combobox.Portal>
          <Combobox.Positioner />
        </Combobox.Portal>
      </Combobox.Root>
    </div>
  );
}

Steps: npx create-next-app, npm install @base-ui/react, paste above as app/page.tsx, npm run dev. Page cannot scroll.

Test plan

  • First commit adds a failing test that reproduces the bug
  • Second commit adds the one-line fix, test passes
  • Run: VITEST_ENV=jsdom npx vitest run packages/react/src/combobox/positioner/ComboboxPositioner.test.tsx

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Apr 2, 2026

commit: 5c6bb9e

@mui-bot
Copy link
Copy Markdown

mui-bot commented Apr 2, 2026

Bundle size report

Bundle Parsed size Gzip size
@base-ui/react 🔺+11B(0.00%) 🔺+6B(0.00%)

Details of bundle changes


Check out the code infra dashboard for more information about this PR.

@netlify
Copy link
Copy Markdown

netlify bot commented Apr 2, 2026

Deploy Preview for base-ui ready!

Name Link
🔨 Latest commit 5c6bb9e
🔍 Latest deploy log https://app.netlify.com/projects/base-ui/deploys/69d371f37464150007910cd0
😎 Deploy Preview https://deploy-preview-4507--base-ui.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

ryanrhee added 2 commits April 3, 2026 00:25
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
@ryanrhee ryanrhee force-pushed the fix/combobox-scroll-lock-undefined branch from 1352a39 to 83cd1a1 Compare April 2, 2026 16:26
Copy link
Copy Markdown
Contributor

@atomiks atomiks left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@atomiks atomiks added type: bug It doesn't behave as expected. component: combobox Changes related to the combobox component. labels Apr 3, 2026
@atomiks atomiks changed the title fix(combobox): prevent scroll lock when controlled value triggers re-render [combobox] Prevent scroll lock when controlled value triggers re-render Apr 6, 2026
@atomiks atomiks merged commit 7991fcd into mui:master Apr 6, 2026
23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

component: combobox Changes related to the combobox component. type: bug It doesn't behave as expected.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Combobox with Chips locks body scroll even with modal={false}

3 participants