Skip to main content

Portaled overlay lists not scrollable inside a modal Dialog (WEB-1286)

Version: 0.9.0 ยท Type: ๐Ÿ› Bug Fix

WEB-1286

Problemโ€‹

Inside a modal Dialog (repro: web4 share dialog โ†’ Link expires โ†’ Custom date โ†’ open the calendar), clicking the calendar's month / year dropdown shows a list taller than its viewport (the year list is the current year ยฑ50 = 101 items), but the wheel / trackpad cannot scroll it โ€” only the top slice is visible and you cannot reach the target year.

The same dropdown scrolls fine in a standalone (non-modal) Popover; the bug only reproduces when the overlay is wrapped by a modal layer (Dialog / modal Popover).

Root Causeโ€‹

Two things combine:

  1. react-remove-scroll isolation. Radix Dialog locks background scroll via react-remove-scroll, whose shards allowlist only contains the Dialog content node. It registers a bubble-phase, non-passive wheel listener on document that, for any wheel whose target is outside the allowlist, takes the isolation branch (noIsolation defaults to false) and calls preventDefault. The DropdownMenu content is portaled to document.body โ€” outside the allowlist โ€” so it is treated as "outside scroll" and blocked.

  2. useStopWheelPropagation never actually attached its listener (the real bug). The hook was meant to stopPropagation on the overlay content so the wheel never reaches that document-level listener. But the old implementation used useEffect(fn, [ref]) reading ref.current: with a stable dependency it runs once at mount, and Radix Content mounts its DOM lazily (Portal / Presence), so at that first run ref.current was still null, if (!el) return bailed out, and the effect never re-ran. By the time the panel actually opened, the listener had already given up.

    Verified with a Playwright CT repro: inside a modal Dialog the dropdown geometry is correct (overflowY: auto, scrollHeight 4052 > clientHeight 280), but the hook's effect ran three times all with ref.current === null (0 attachments); the wheel bubbled to document and react-remove-scroll preventDefaulted it (defaultPrevented: true). Manually adding a stopPropagation wheel listener on the content node restored scrolling โ€” confirming the fix direction.

Changed Filesโ€‹

  • packages/design/src/utils/use-stop-wheel-propagation.ts
  • packages/design/src/components/ui/dropdown-menu.tsx
  • packages/design/src/components/ui/context-menu.tsx
  • packages/design/src/components/ui/menubar.tsx
  • packages/design/src/components/ui/popover.tsx
  • packages/design/src/components/ui/select.tsx
  • packages/design/src/utils/__tests__/use-stop-wheel-propagation.test.ts

Changesโ€‹

  1. Rewrote useStopWheelPropagation from "take a RefObject + read ref.current in useEffect" into returning a stable ref callback (React.RefCallback<T>, wrapped in useCallback). It binds the wheel stopPropagation listener at the exact moment React assigns the DOM node to the ref (including lazily-mounted portaled content) and unbinds it when the node unmounts (ref receives null), tracking cleanup via a useRef. This sidesteps the "node not mounted yet at the effect's first run" timing trap entirely.
  2. Updated all consumers from const localRef = useRef(...); useStopWheelPropagation(localRef); ref={mergeRefs(ref, localRef)} to const stopWheelRef = useStopWheelPropagation<...>(); ref={mergeRefs(ref, stopWheelRef)} โ€” 8 sites across DropdownMenu, ContextMenu, Menubar (Content + SubContent), Popover, Select.
  3. Added a hook unit test asserting the wheel no longer bubbles to an ancestor once bound, bubbling is restored after ref(null), and the ref callback is stable across re-renders.

Impactโ€‹

Every Portal-rendered overlay (DropdownMenu / ContextMenu / Menubar / Popover / Select) can now wheel-scroll its internal list when wrapped by a react-remove-scroll layer (modal Dialog, modal Popover, โ€ฆ). Non-modal behavior is unchanged (stopping wheel propagation has no side effect on a plain dropdown: when its content is not scrollable it neither scrolls nor propagates, which is the expected overlay behavior). Since the hook was effectively a no-op before, this fix changes no visuals โ€” it just makes the intended "stop wheel propagation" behavior actually take effect.