Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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

"type": "patch",
"comment": "fix: preserve Combobox listbox scrollTop by stabilizing positioning options to prevent manager recreation on every render (#35731)",
"packageName": "@fluentui/react-combobox",
"email": "198982749+Copilot@users.noreply.github.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ import { resolvePositioningShorthand, usePositioning } from '@fluentui/react-pos
import type { ComboboxBaseProps } from './ComboboxBase.types';
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.

// to recreate on every render, which disposes and recreates the position manager, triggering
// autoSize middleware (resetMaxSize) that temporarily removes height constraints from the listbox
// and resets scrollTop to 0. See: https://github.com/microsoft/fluentui/issues/35731
const DEFAULT_FALLBACK_POSITIONS: PositioningShorthandValue[] = ['above', 'after', 'after-top', 'before', 'before-top'];
const DEFAULT_OFFSET = { crossAxis: 0, mainAxis: 2 } as const;

export function useComboboxPositioning(props: ComboboxBaseProps): [
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-deprecated
listboxRef: React.MutableRefObject<any>,
Expand All @@ -13,15 +21,12 @@ export function useComboboxPositioning(props: ComboboxBaseProps): [
] {
const { positioning } = props;

// Set a default set of fallback positions to try if the dropdown does not fit on screen
const fallbackPositions: PositioningShorthandValue[] = ['above', 'after', 'after-top', 'before', 'before-top'];

// popper options
const popperOptions = {
position: 'below' as const,
align: 'start' as const,
offset: { crossAxis: 0, mainAxis: 2 },
fallbackPositions,
offset: DEFAULT_OFFSET,
fallbackPositions: DEFAULT_FALLBACK_POSITIONS,
matchTargetSize: 'width' as const,
autoSize: true,
...resolvePositioningShorthand(positioning),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import * as React from 'react';
import type { JSXElement } from '@fluentui/react-components';
import { Combobox, makeStyles, Option, useId } from '@fluentui/react-components';
import type { ComboboxProps } from '@fluentui/react-components';

const useStyles = makeStyles({
root: {
// Stack the label above the field with a gap
display: 'grid',
gridTemplateRows: 'repeat(1fr)',
justifyItems: 'start',
gap: '2px',
maxWidth: '400px',
},
});

const options = Array.from({ length: 50 }, (_, i) => `Option ${i + 1}`);

/**
* Demonstrates that the listbox scrollTop is preserved when a parent component updates
* state in response to hovering an option (e.g. tracking the currently-hovered item).
*
* Previously, each state update caused the Combobox to re-render, which recreated the
* positioning manager and triggered the autoSize middleware to reset the listbox scrollTop
* to 0, making the last options in a long list unreachable.
* See: https://github.com/microsoft/fluentui/issues/35731
*/
export const ScrollStability = (props: Partial<ComboboxProps>): JSXElement => {
const comboId = useId('combo-scroll-stability');
const styles = useStyles();
const [hoveredOption, setHoveredOption] = React.useState<string | null>(null);

const onMouseEnter = React.useCallback((e: React.MouseEvent<HTMLElement>) => {
const value = (e.currentTarget as HTMLElement).dataset.value ?? null;
setHoveredOption(value);
}, []);

return (
<div className={styles.root}>
<label id={comboId}>Scroll-stable Combobox (50 options)</label>
<span>Hovered: {hoveredOption ?? 'none'}</span>
<Combobox aria-labelledby={comboId} placeholder="Select an option" {...props}>
{options.map(option => (
<Option key={option} data-value={option} onMouseEnter={onMouseEnter}>
{option}
</Option>
))}
</Combobox>
</div>
);
};

ScrollStability.parameters = {
docs: {
description: {
story:
'A Combobox with many options where a parent component tracks the hovered option in state. ' +
'Scrolling to the bottom of the list and hovering a low option should not cause the listbox ' +
'to jump back to the top. This validates the fix for https://github.com/microsoft/fluentui/issues/35731.',
},
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export { Disabled } from './ComboboxDisabled.stories';
export { ComboboxVirtualizer as Virtualizer } from './ComboboxVirtualizer.stories';
export { ActiveOptionChange } from './ComboboxActiveOptionChange.stories';
export { ControllingOpenAndClose } from './ComboboxControllingOpenAndClose.stories';
export { ScrollStability } from './ComboboxScrollStability.stories';

export default {
title: 'Components/Combobox',
Expand Down
Loading