-
Notifications
You must be signed in to change notification settings - Fork 2.9k
Dropdown with inlinePopup scrolls parent container to top when opened inside a scrollable Dialog #35921
Description
Environment
- Package:
@fluentui/react-aria(scrollIntoView),@fluentui/react-positioning(createPositionManager) - Version:
@fluentui/react-combobox@9.16.16,@fluentui/react-aria@9.17.10 - React: 18 with
createRoot
Description
When a Dropdown with inlinePopup is inside a scrollable container within a Dialog, opening the dropdown scrolls the parent container to the top. This is caused by a race condition between positioning initialization and active descendant focus.
Repro
Repository: https://github.com/layershifter/fluent-dropdown-scroll-repro
git clone https://github.com/layershifter/fluent-dropdown-scroll-repro
cd fluent-dropdown-scroll-repro
npm install
npm run dev- Scroll down to the "Allow Copilot" dropdown at the bottom
- Click it
- The scrollable container jumps to the top
Note: The repro includes a `useForcePositionRace` hook that holds the listbox's initial `position: fixed` for two animation frames. This simulates the timing in complex apps (e.g., Microsoft Teams with 100+ React component layers) where the React passive effect fires before `computePosition` resolves. Without this hook, the race doesn't trigger in a minimal repro because `computePosition` resolves fast enough.
Root Cause
Race condition between `createPositionManager` and `useActiveDescendant`:
-
`createPositionManager.js:42-43` initially sets `position: fixed` on the listbox container to "avoid scroll jumps":
Object.assign(container.style, { position: 'fixed', left: 0, top: 0, margin: 0 });
-
`computePosition()` runs async — the `.then()` callback later sets the actual strategy (`position: absolute`):
Object.assign(container.style, { position: strategy // 'absolute' });
-
React passive effect fires `activeDescendantController.focus(selectedOption.id)` (in `useComboboxBaseState.js:127`) → calls `scrollIntoView()` while the listbox still has `position: fixed`.
-
`scrollIntoView` (`scrollIntoView.ts`) → `findScrollableParent` walks from the option element up past the fixed-positioned listbox (which isn't scrollable) to the outer scrollable container.
-
`getTotalOffsetTop` walks the `offsetParent` chain:
option.offsetTop (4) + listbox.offsetTop (0 — position:fixed always reports 0) + (-scrollParent.offsetTop) (-113 — via element.contains(scrollParent) branch) = -109 -
`scrollTo(0, -109 - 0 - 2)` = `scrollTo(0, -111)` → browser clamps to 0 → container jumps to top.
Debugger Evidence
Values captured at the `scrollIntoView` breakpoint in the Microsoft Teams app:
| Variable | Value |
|---|---|
| `offsetTop` | `-109` |
| `scrollTop` | `1977.5` |
| `isAbove` | `true` |
| `offsetHeight` | `32` |
| `parentOffsetHeight` | `550` |
| `scrollMarginTop` | `0` |
`getTotalOffsetTop` recursion:
| Step | Element | `position` | `offsetTop` | Running Total |
|---|---|---|---|---|
| 0 | `div#option1.fui-Option` | `relative` | `4` | `4` |
| 1 | `div#fluent-listbox.fui-Listbox` | `fixed` | `0` | `4` |
| 2 | `div.fui-DialogSurface` | `fixed` | — | `4 + (-113) = -109` |
Suggested Fix
The `getTotalOffsetTop` function doesn't account for `position: fixed` elements in the `offsetParent` chain. Their `offsetTop` is always 0 regardless of visual position, making the additive calculation incorrect.
Options:
- Skip `position: fixed` elements in `getTotalOffsetTop` or use `getBoundingClientRect()` for them
- Don't set initial `position: fixed` in `createPositionManager` — or defer the `scrollIntoView` call until after positioning resolves
- Have `findScrollableParent` stop at the Dropdown's own container boundary instead of walking up to the Dialog
Workaround
Remove `inlinePopup` from the `Dropdown`. Without it, the listbox renders in a Portal at `document.body`, outside the scrollable container's DOM tree, so `findScrollableParent` never reaches it.