Portaled overlay lists not scrollable inside a modal Dialog (WEB-1286)
Version: 0.9.0 ยท Type: ๐ Bug Fix
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:
-
react-remove-scrollisolation. RadixDialoglocks background scroll viareact-remove-scroll, whoseshardsallowlist only contains the Dialog content node. It registers a bubble-phase, non-passivewheellistener ondocumentthat, for any wheel whose target is outside the allowlist, takes the isolation branch (noIsolationdefaults tofalse) and callspreventDefault. TheDropdownMenucontent is portaled todocument.bodyโ outside the allowlist โ so it is treated as "outside scroll" and blocked. -
useStopWheelPropagationnever actually attached its listener (the real bug). The hook was meant tostopPropagationon the overlay content so the wheel never reaches that document-level listener. But the old implementation useduseEffect(fn, [ref])readingref.current: with a stable dependency it runs once at mount, and RadixContentmounts its DOM lazily (Portal / Presence), so at that first runref.currentwas stillnull,if (!el) returnbailed 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 withref.current === null(0 attachments); the wheel bubbled todocumentandreact-remove-scrollpreventDefaulted it (defaultPrevented: true). Manually adding astopPropagationwheel listener on the content node restored scrolling โ confirming the fix direction.
Changed Filesโ
packages/design/src/utils/use-stop-wheel-propagation.tspackages/design/src/components/ui/dropdown-menu.tsxpackages/design/src/components/ui/context-menu.tsxpackages/design/src/components/ui/menubar.tsxpackages/design/src/components/ui/popover.tsxpackages/design/src/components/ui/select.tsxpackages/design/src/utils/__tests__/use-stop-wheel-propagation.test.ts
Changesโ
- Rewrote
useStopWheelPropagationfrom "take aRefObject+ readref.currentinuseEffect" into returning a stable ref callback (React.RefCallback<T>, wrapped inuseCallback). It binds thewheelstopPropagationlistener 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 receivesnull), tracking cleanup via auseRef. This sidesteps the "node not mounted yet at the effect's first run" timing trap entirely. - Updated all consumers from
const localRef = useRef(...); useStopWheelPropagation(localRef); ref={mergeRefs(ref, localRef)}toconst stopWheelRef = useStopWheelPropagation<...>(); ref={mergeRefs(ref, stopWheelRef)}โ 8 sites acrossDropdownMenu,ContextMenu,Menubar(Content + SubContent),Popover,Select. - 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.