Skip to content

Commit 23c05d1

Browse files
authored
fix(react-overflow,priority-overflow): correct overflow snapshot on first paint (#36264)
1 parent fdf755c commit 23c05d1

16 files changed

Lines changed: 562 additions & 67 deletions
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "patch",
3+
"comment": "fix: recompute overflow eagerly when the overflow menu attaches or detaches",
4+
"packageName": "@fluentui/priority-overflow",
5+
"email": "bsunderhus@microsoft.com",
6+
"dependentChangeType": "patch"
7+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "minor",
3+
"comment": "feat: produce a correct overflow snapshot before the first paint",
4+
"packageName": "@fluentui/react-overflow",
5+
"email": "bsunderhus@microsoft.com",
6+
"dependentChangeType": "patch"
7+
}

packages/react-components/priority-overflow/etc/priority-overflow.api.md

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,14 @@
55
```ts
66

77
// @internal
8-
export function createOverflowManager(initialOptions?: Partial<ObserveOptions>): OverflowManager;
8+
export function createOverflowManager(initialOptions?: Partial<OverflowOptions>): OverflowManager;
99

1010
// @internal
1111
export const EMPTY_SNAPSHOT: OverflowSnapshot;
1212

13-
// @public
14-
export interface ObserveOptions {
15-
hasHiddenItems?: boolean;
16-
minimumVisible?: number;
17-
onUpdateItemVisibility: OnUpdateItemVisibility;
18-
onUpdateOverflow: OnUpdateOverflow;
19-
overflowAxis?: OverflowAxis;
20-
overflowDirection?: OverflowDirection;
21-
padding?: number;
13+
// @public (undocumented)
14+
export interface ObserveOptions extends Partial<OverflowOptions> {
15+
forceUpdate?: boolean;
2216
}
2317

2418
// @public
@@ -76,11 +70,22 @@ export interface OverflowManager {
7670
removeDivider: (groupId: string) => void;
7771
removeItem: (itemId: string) => void;
7872
removeOverflowMenu: () => void;
79-
setOptions: (options: Partial<ObserveOptions>) => void;
73+
setOptions: (options: Partial<OverflowOptions>) => void;
8074
subscribe: (listener: () => void) => () => void;
8175
update: () => void;
8276
}
8377

78+
// @public
79+
export interface OverflowOptions {
80+
hasHiddenItems?: boolean;
81+
minimumVisible?: number;
82+
onUpdateItemVisibility: OnUpdateItemVisibility;
83+
onUpdateOverflow: OnUpdateOverflow;
84+
overflowAxis?: OverflowAxis;
85+
overflowDirection?: OverflowDirection;
86+
padding?: number;
87+
}
88+
8489
// @public
8590
export interface OverflowSnapshot {
8691
groupVisibility: Record<string, OverflowGroupState>;

packages/react-components/priority-overflow/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export { createOverflowManager } from './overflowManager';
22
export { EMPTY_SNAPSHOT } from './consts';
33
export type {
44
ObserveOptions,
5+
OverflowOptions,
56
OnUpdateItemVisibility,
67
OnUpdateItemVisibilityPayload,
78
OnUpdateOverflow,

packages/react-components/priority-overflow/src/overflowManager.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,25 @@ describe('overflowManager', () => {
136136
});
137137
});
138138

139+
it('should re-dispatch when the overflow menu is attached while observing', () => {
140+
const onUpdateOverflow = jest.fn();
141+
const manager = createOverflowManager(createObserveOptions({ onUpdateOverflow }));
142+
const container = createContainer(100);
143+
const itemA = createElementWithSize('button', 60);
144+
const itemB = createElementWithSize('button', 60);
145+
146+
manager.addItem({ element: itemA, id: 'a', priority: 1 });
147+
manager.addItem({ element: itemB, id: 'b', priority: 0 });
148+
manager.observe(container);
149+
manager.forceUpdate();
150+
onUpdateOverflow.mockClear();
151+
152+
const menu = createElementWithSize('button', 30);
153+
manager.addOverflowMenu(menu);
154+
155+
expect(onUpdateOverflow).toHaveBeenCalled();
156+
});
157+
139158
it('should remove items through removeItem', () => {
140159
const manager = createOverflowManager(createObserveOptions());
141160
const container = createContainer(100);
@@ -156,4 +175,52 @@ describe('overflowManager', () => {
156175
invisibleItemCount: 0,
157176
});
158177
});
178+
179+
it('resolves overflow synchronously when observed with forceUpdate and the container is measured', () => {
180+
const manager = createOverflowManager(createObserveOptions());
181+
const container = createContainer(100);
182+
const itemA = createElementWithSize('button', 60);
183+
const itemB = createElementWithSize('button', 60);
184+
const menu = createElementWithSize('button', 30);
185+
186+
manager.addItem({ element: itemA, id: 'a', priority: 1 });
187+
manager.addItem({ element: itemB, id: 'b', priority: 0 });
188+
manager.addOverflowMenu(menu);
189+
190+
// No manual forceUpdate(); observing with forceUpdate resolves overflow on its own.
191+
manager.observe(container, { forceUpdate: true });
192+
193+
expect(getVisibleIds(manager)).toEqual(['a']);
194+
expect(getInvisibleIds(manager)).toEqual(['b']);
195+
});
196+
197+
it('does not resolve overflow on observe when forceUpdate is not requested', () => {
198+
const manager = createOverflowManager(createObserveOptions());
199+
const container = createContainer(100);
200+
const itemA = createElementWithSize('button', 60);
201+
const itemB = createElementWithSize('button', 60);
202+
203+
manager.addItem({ element: itemA, id: 'a', priority: 1 });
204+
manager.addItem({ element: itemB, id: 'b', priority: 0 });
205+
206+
manager.observe(container);
207+
208+
// Nothing has been computed yet (the ResizeObserver is mocked to a noop).
209+
expect(manager.getSnapshot().itemVisibility).toEqual({});
210+
});
211+
212+
it('does not resolve overflow on observe with forceUpdate when the container is not measured', () => {
213+
const manager = createOverflowManager(createObserveOptions());
214+
const container = createContainer(0);
215+
const itemA = createElementWithSize('button', 60);
216+
const itemB = createElementWithSize('button', 60);
217+
218+
manager.addItem({ element: itemA, id: 'a', priority: 1 });
219+
manager.addItem({ element: itemB, id: 'b', priority: 0 });
220+
221+
// Degenerate 0 size — the guard skips the force so nothing collapses.
222+
manager.observe(container, { forceUpdate: true });
223+
224+
expect(manager.getSnapshot().itemVisibility).toEqual({});
225+
});
159226
});

packages/react-components/priority-overflow/src/overflowManager.ts

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@ import type {
77
OverflowGroupState,
88
OverflowItemEntry,
99
OverflowManager,
10-
ObserveOptions,
10+
OverflowOptions,
1111
OverflowDividerEntry,
1212
OverflowSnapshot,
1313
} from './types';
1414

15-
const DEFAULT_OPTIONS: Required<ObserveOptions> = {
15+
const DEFAULT_OPTIONS: Required<OverflowOptions> = {
1616
overflowAxis: 'horizontal',
1717
overflowDirection: 'end',
1818
padding: 10,
@@ -33,7 +33,7 @@ const DEFAULT_OPTIONS: Required<ObserveOptions> = {
3333
* @param initialOptions - Initial observe options. Missing values are filled with defaults.
3434
* @returns overflow manager instance
3535
*/
36-
export function createOverflowManager(initialOptions: Partial<ObserveOptions> = {}): OverflowManager {
36+
export function createOverflowManager(initialOptions: Partial<OverflowOptions> = {}): OverflowManager {
3737
// calls to `offsetWidth or offsetHeight` can happen multiple times in an update
3838
// Use a cache to avoid causing too many recalcs and avoid scripting time to meausure sizes
3939
const sizeCache = new Map<HTMLElement, number>();
@@ -44,7 +44,7 @@ export function createOverflowManager(initialOptions: Partial<ObserveOptions> =
4444
// If true, next update will dispatch to onUpdateOverflow even if queue top states don't change
4545
// Initially true to force dispatch on first mount
4646
let forceDispatch = true;
47-
const options: Required<ObserveOptions> = { ...DEFAULT_OPTIONS, ...initialOptions };
47+
const options: Required<OverflowOptions> = { ...DEFAULT_OPTIONS, ...initialOptions };
4848
const overflowItems: Record<string, OverflowItemEntry> = {};
4949
const overflowDividers: Record<string, OverflowDividerEntry> = {};
5050
const listeners = new Set<() => void>();
@@ -264,10 +264,10 @@ export function createOverflowManager(initialOptions: Partial<ObserveOptions> =
264264
}
265265
};
266266

267-
const observe: OverflowManager['observe'] = (observedContainer, userOptions) => {
268-
if (userOptions) {
269-
Object.assign(options, userOptions);
270-
}
267+
const observe: OverflowManager['observe'] = (observedContainer, observeOptions) => {
268+
const { forceUpdate: shouldForceUpdate, ...userOptions } = observeOptions ?? {};
269+
Object.assign(options, userOptions);
270+
271271
Object.values(overflowItems).forEach(item => {
272272
if (!visibleItemQueue.contains(item.id) && !invisibleItemQueue.contains(item.id)) {
273273
visibleItemQueue.enqueue(item.id);
@@ -282,6 +282,10 @@ export function createOverflowManager(initialOptions: Partial<ObserveOptions> =
282282
}
283283
update();
284284
});
285+
286+
if (shouldForceUpdate && getClientSize(observedContainer) > 0) {
287+
forceUpdate();
288+
}
285289
};
286290

287291
const disconnect: OverflowManager['disconnect'] = () => {
@@ -330,6 +334,11 @@ export function createOverflowManager(initialOptions: Partial<ObserveOptions> =
330334

331335
const addOverflowMenu: OverflowManager['addOverflowMenu'] = el => {
332336
overflowMenu = el;
337+
338+
if (observing) {
339+
forceDispatch = true;
340+
update();
341+
}
333342
};
334343

335344
const addDivider: OverflowManager['addDivider'] = divider => {
@@ -343,6 +352,11 @@ export function createOverflowManager(initialOptions: Partial<ObserveOptions> =
343352

344353
const removeOverflowMenu: OverflowManager['removeOverflowMenu'] = () => {
345354
overflowMenu = undefined;
355+
356+
if (observing) {
357+
forceDispatch = true;
358+
update();
359+
}
346360
};
347361

348362
const removeDivider: OverflowManager['removeDivider'] = groupId => {

packages/react-components/priority-overflow/src/types.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ export interface OnUpdateItemVisibilityPayload {
130130
/**
131131
* Options used to initialize or reconfigure overflow observation.
132132
*/
133-
export interface ObserveOptions {
133+
export interface OverflowOptions {
134134
/**
135135
* Padding in pixels reserved at the end of the container before overflow occurs.
136136
* Useful for accounting for extra elements (for example an overflow menu button)
@@ -172,6 +172,14 @@ export interface ObserveOptions {
172172
hasHiddenItems?: boolean;
173173
}
174174

175+
export interface ObserveOptions extends Partial<OverflowOptions> {
176+
/**
177+
* forces update when observation begins, ensuring initial overflow state is correct. This is useful when the container starts with items that should be overflowed, or when the container resizes immediately after mounting.
178+
* @default false
179+
*/
180+
forceUpdate?: boolean;
181+
}
182+
175183
/**
176184
* Internal manager contract used to observe and compute priority overflow.
177185
*
@@ -189,7 +197,7 @@ export interface OverflowManager {
189197
/**
190198
* Updates engine options without restarting observation.
191199
*/
192-
setOptions: (options: Partial<ObserveOptions>) => void;
200+
setOptions: (options: Partial<OverflowOptions>) => void;
193201
/**
194202
* Add overflow items
195203
*/

packages/react-components/react-overflow/library/etc/react-overflow.api.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@
44
55
```ts
66

7-
import type { ObserveOptions } from '@fluentui/priority-overflow';
87
import type { OnUpdateOverflow } from '@fluentui/priority-overflow';
98
import type { OverflowDividerEntry } from '@fluentui/priority-overflow';
109
import type { OverflowGroupState } from '@fluentui/priority-overflow';
1110
import type { OverflowItemEntry } from '@fluentui/priority-overflow';
11+
import type { OverflowOptions } from '@fluentui/priority-overflow';
1212
import type { OverflowSnapshot } from '@fluentui/priority-overflow';
1313
import * as React_2 from 'react';
1414

@@ -29,7 +29,7 @@ export interface OnOverflowChangeData extends OverflowState {
2929
}
3030

3131
// @public
32-
export const Overflow: React_2.ForwardRefExoticComponent<Partial<Pick<ObserveOptions, "padding" | "overflowDirection" | "overflowAxis" | "minimumVisible" | "hasHiddenItems">> & {
32+
export const Overflow: React_2.ForwardRefExoticComponent<Partial<Pick<OverflowOptions, "padding" | "overflowDirection" | "overflowAxis" | "minimumVisible" | "hasHiddenItems">> & {
3333
children: React_2.ReactElement;
3434
onOverflowChange?: (ev: null, data: OverflowState) => void;
3535
} & React_2.RefAttributes<unknown>>;
@@ -54,7 +54,7 @@ export type OverflowItemProps = {
5454
});
5555

5656
// @public
57-
export type OverflowProps = Partial<Pick<ObserveOptions, 'overflowAxis' | 'overflowDirection' | 'padding' | 'minimumVisible' | 'hasHiddenItems'>> & {
57+
export type OverflowProps = Partial<Pick<OverflowOptions, 'overflowAxis' | 'overflowDirection' | 'padding' | 'minimumVisible' | 'hasHiddenItems'>> & {
5858
children: React_2.ReactElement;
5959
onOverflowChange?: (ev: null, data: OverflowState) => void;
6060
};
@@ -69,10 +69,10 @@ export function useIsOverflowGroupVisible(id: string): OverflowGroupState;
6969
export function useIsOverflowItemVisible(id: string): boolean;
7070

7171
// @internal (undocumented)
72-
export const useOverflowContainer: <TElement extends HTMLElement>(update: OnUpdateOverflow, options: Omit<ObserveOptions, "onUpdateOverflow">) => UseOverflowContainerReturn<TElement>;
72+
export const useOverflowContainer: <TElement extends HTMLElement>(update: OnUpdateOverflow, options: Omit<OverflowOptions, "onUpdateOverflow">) => UseOverflowContainerReturn<TElement>;
7373

7474
// @internal (undocumented)
75-
export interface UseOverflowContainerReturn<TElement extends HTMLElement> extends Pick<OverflowContextValue, 'registerItem' | 'updateOverflow' | 'registerOverflowMenu' | 'registerDivider' | 'getSnapshot' | 'subscribe'> {
75+
export interface UseOverflowContainerReturn<TElement extends HTMLElement> extends Pick<OverflowContextValue, 'registerItem' | 'updateOverflow' | 'forceUpdateOverflow' | 'registerOverflowMenu' | 'registerDivider' | 'getSnapshot' | 'subscribe'> {
7676
containerRef: React_2.RefObject<TElement | null>;
7777
}
7878

0 commit comments

Comments
 (0)