Skip to content

fix(react-combobox): preserve listbox scrollTop across parent re-renders caused by autoSize middleware#36039

Open
Copilot wants to merge 5 commits intomasterfrom
copilot/fix-combobox-autosize-scrolling
Open

fix(react-combobox): preserve listbox scrollTop across parent re-renders caused by autoSize middleware#36039
Copilot wants to merge 5 commits intomasterfrom
copilot/fix-combobox-autosize-scrolling

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 24, 2026

Problem

When a parent component updates state in response to hovering a Combobox option (e.g. tracking the hovered item), every state update caused the positioning manager to be fully disposed and recreated. This triggered the resetMaxSize autoSize middleware, which temporarily removes height/max-height from the listbox — causing the browser to reset scrollTop to 0. The result: items near the bottom of a long list were unreachable because scrolling down and hovering any item immediately jumped the list back to the top.

Root Cause

In useComboboxPositioning, offset and fallbackPositions were created as new object/array literals on every render:

// Before — new references every render
const fallbackPositions: PositioningShorthandValue[] = ['above', 'after', ...];
const popperOptions = {
  offset: { crossAxis: 0, mainAxis: 2 },  // new object each render
  fallbackPositions,                        // new array each render
  ...
};

These flow into usePositioningConfigFn's useCallback as dependencies. New references → new callback → updatePositionManager recreated → position manager disposed and rebuilt → updatePosition called → resetMaxSize strips height constraints → scrollTop resets.

Fix

  • useComboboxPositioning.ts: Promote offset and fallbackPositions to module-level constants (DEFAULT_OFFSET, DEFAULT_FALLBACK_POSITIONS). Stable references mean the position manager is only recreated when positioning actually changes (mount, window resize/scroll, genuine container dimension change) — not on every parent re-render.
// After — stable module-level constants
const DEFAULT_FALLBACK_POSITIONS: PositioningShorthandValue[] = ['above', 'after', 'after-top', 'before', 'before-top'];
const DEFAULT_OFFSET = { crossAxis: 0, mainAxis: 2 } as const;
  • New story ComboboxScrollStability: 50-option Combobox where a parent tracks the hovered option in state — the exact scenario from the bug report — to document the expected behavior.

Copilot AI and others added 3 commits April 24, 2026 13:11
Co-authored-by: tudorpopams <97875118+tudorpopams@users.noreply.github.com>
…lTop on re-render (#35731)

Agent-Logs-Url: https://github.com/microsoft/fluentui/sessions/f1f92f2a-b1f9-42d9-a861-32b7b3d5f1d7

Co-authored-by: tudorpopams <97875118+tudorpopams@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix autosize scrolling issue in Combobox component fix(react-combobox): preserve listbox scrollTop across parent re-renders caused by autoSize middleware Apr 24, 2026
Copilot AI requested a review from tudorpopams April 24, 2026 13:15
import type * as React from 'react';

// Stable module-level constants prevent new object/array references on every render.
// Without these constants, new references would cause usePositioningConfigFn's useCallback
Copy link
Copy Markdown
Contributor

@tudorpopams tudorpopams Apr 24, 2026

Choose a reason for hiding this comment

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

@copilot

Nit: the block references usePositioningConfigFn's useCallback but that's an internal implementation detail of @fluentui/react-positioning — reviewers unfamiliar with that hook have to go spelunking to verify the causal chain. Could you either:

  1. Trim the comment to the observable effect — e.g. "Module-level constants so popperOptions has stable references across renders, preventing the position manager from being torn down and rebuilt on each parent re-render (which would reset scrollTop via autoSize's resetMaxSize)." — and drop the useCallback reference, or
  2. Keep the detail but point at the specific file (e.g. @fluentui/react-positioning/src/usePositioningConfigFn.ts or wherever the useCallback lives) so readers can trace it without guessing.

Not blocking — the fix itself is correct and the cause is well-chosen. Just a readability thing for the next person who hits this code.

@tudorpopams tudorpopams marked this pull request as ready for review April 24, 2026 14:58
@tudorpopams tudorpopams requested review from a team and dmytrokirpa as code owners April 24, 2026 14:58
@tudorpopams
Copy link
Copy Markdown
Contributor

PR Review: #36039 — fix(react-combobox): preserve listbox scrollTop across parent re-renders

Author: app/copilot-swe-agent
Type: bug-fix
Packages affected: @fluentui/react-combobox
CI Status: passing (all 15 checks green, including e2e, VR screenshots, CodeQL)

Confidence Score: 100/100

Fix went deeper than the original hypothesis: instead of the save/restore-scrollTop pattern the Copilot brief suggested, this traces the actual root cause — offset and fallbackPositions being new object/array literals every render flowing into usePositioningConfigFn's useCallback dependency list, tearing down and rebuilding the position manager on every parent state change, which triggered autoSize's resetMaxSize. The fix is just promoting those two literals to module-level constants (DEFAULT_OFFSET, DEFAULT_FALLBACK_POSITIONS). Five real lines of change; no autoSize disable, no scrollTop bookkeeping, no API surface.

Findings

Blockers (must fix before merge)

none

Warnings (should address)

none

Info (consider)

  • Existing open inline thread. I left a nit on line 9 earlier (PR #36039 discussion_r3138229151) — the comment block references usePositioningConfigFn's useCallback which is an internal react-positioning detail reviewers have to trace. Two cheap options: trim to observable effect ("stable references prevent manager rebuild on parent re-render, which would reset scrollTop via autoSize's resetMaxSize") or name the file. Not blocking — just left unresolved as of this head SHA; pointing it out so it doesn't get lost in the reading.
  • No jsdom unit test. The PR ships a new ComboboxScrollStability story instead. That's appropriate here — the bug is a cross-render autoSize interaction in a real browser; a jsdom test stubbing usePositioningConfigFn's internals would be brittle and mostly test the stub. The story is the right regression check.
  • Independently verified in a prior session via the per-component Storybook (yarn nx run react-combobox-stories:storybook): scrolled listbox to scrollTop=1128 (max), hovered 5 low options in rapid succession → parent state updated to "Hovered: Option 48" each time → scrollTop stayed at 1128 across all hovers. Pre-fix this would snap back to 0 on the first hover. Screenshot confirmed Option 48 highlighted while the view still showed Options 34–50.

Category Breakdown

Category Status Notes
Change file PASS @fluentui-react-combobox-522868f4-...json, type patch, correctly cites #35731.
V9 patterns PASS Internal positioning utility; no React.FC, no styles touched. The existing usePositioning + resolvePositioningShorthand contract is preserved.
Dep layers PASS No package.json or new-import changes.
SSR safety PASS Pure values; no browser-API access added.
Testing PASS (with nuance) Story added (ComboboxScrollStability), registered in index.stories.tsx, documented with a docs-description that names the bug # so the regression context travels with the story. No unit test — reasonable given the nature of the bug (see Info above).
API surface PASS No public change. etc/react-combobox.api.md untouched (correctly).
Accessibility PASS No aria changes. The fix actually helps a11y indirectly by making long lists fully keyboard-reachable once the scroll stops resetting.
Security/Quality PASS No eval/dangerouslySetInnerHTML/console.log/@ts-ignore/any.

Recommendation

APPROVE

The fix is smaller than I expected when I wrote the brief — and better. Shipping is safe; the inline comment-block nit is a polish pass that can land with the PR or in a follow-up. The new story plus the manual Storybook validation cover the regression surface that matters, and the root-cause analysis in the PR body is unusually precise for this kind of cross-render interaction bug.


Posted via the /review-pr skill.

@tudorpopams tudorpopams enabled auto-merge (squash) April 24, 2026 16:28
@github-actions
Copy link
Copy Markdown

📊 Bundle size report

Package & Exports Baseline (minified/GZIP) PR Change
react-combobox
Combobox (including child components)
106.709 kB
34.573 kB
106.727 kB
34.581 kB
18 B
8 B
react-combobox
Dropdown (including child components)
106.479 kB
34.32 kB
106.497 kB
34.324 kB
18 B
4 B
react-components
react-components: entire library
1.302 MB
325.338 kB
1.302 MB
325.36 kB
12 B
22 B
react-timepicker-compat
TimePicker
109.674 kB
36.193 kB
109.692 kB
36.203 kB
18 B
10 B
Unchanged fixtures
Package & Exports Size (minified/GZIP)
react-components
react-components: Button, FluentProvider & webLightTheme
70.415 kB
19.963 kB
react-components
react-components: Accordion, Button, FluentProvider, Image, Menu, Popover
237.29 kB
68.851 kB
react-components
react-components: FluentProvider & webLightTheme
43.63 kB
14.026 kB
react-portal-compat
PortalCompatProvider
8.386 kB
2.624 kB
react-tag-picker
@fluentui/react-tag-picker - package
187.014 kB
55.911 kB
🤖 This report was generated against 0392152eacf970d6d7063c12af84b20b7d45c8e1

@github-actions
Copy link
Copy Markdown

Pull request demo site: URL

@@ -0,0 +1,7 @@
{
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🕵🏾‍♀️ visual changes to review in the Visual Change Report

vr-tests-react-components/Charts-DonutChart 2 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Charts-DonutChart.Dynamic - Dark Mode.default.chromium.png 7530 Changed
vr-tests-react-components/Charts-DonutChart.Dynamic - RTL.default.chromium.png 5570 Changed
vr-tests-react-components/Menu Converged - submenuIndicator slotted content 1 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Menu Converged - submenuIndicator slotted content.default.submenus open.chromium.png 413 Changed
vr-tests-react-components/Positioning 2 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Positioning.Positioning end.chromium.png 500 Changed
vr-tests-react-components/Positioning.Positioning end.updated 2 times.chromium.png 614 Changed
vr-tests-react-components/ProgressBar converged 2 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/ProgressBar converged.Indeterminate + thickness - Dark Mode.default.chromium.png 34 Changed
vr-tests-react-components/ProgressBar converged.Indeterminate + thickness.default.chromium.png 40 Changed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Combobox autosize causes unwanted scrolling when updating state

2 participants