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.
Environment
@fluentui/react-aria(scrollIntoView),@fluentui/react-positioning(createPositionManager)@fluentui/react-combobox@9.16.16,@fluentui/react-aria@9.17.10createRootDescription
When a
DropdownwithinlinePopupis inside a scrollable container within aDialog, 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 devRoot Cause
Race condition between `createPositionManager` and `useActiveDescendant`:
`createPositionManager.js:42-43` initially sets `position: fixed` on the listbox container to "avoid scroll jumps":
`computePosition()` runs async — the `.then()` callback later sets the actual strategy (`position: 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:
`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:
`getTotalOffsetTop` recursion:
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:
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.