Skip to content

Commit e8fe764

Browse files
bsunderhusclaude
andcommitted
refactor(react-overflow,priority-overflow): subscribe model removes intermediate state from Overflow container
The Overflow container previously held the overflow snapshot in component state and re-rendered every consumer through context selector when it changed. This forced consumers to opt out via memoization and made first-paint sequencing fragile. Move the snapshot into the manager and let each hook subscribe directly. priority-overflow: - the manager caches the latest snapshot and exposes getSnapshot/subscribe - dispatchOverflowUpdate goes through takeSnapshot, which fans out to listeners - the observe cleanup resets the snapshot so subscribers see an empty state react-overflow: - OverflowContextValue exposes getSnapshot/subscribe directly from the manager - the context drops react-context-selector and uses plain React.createContext; useOverflowContext keeps the selector overload for backward compatibility - the existing itemVisibility, groupVisibility and hasOverflow fields on the context are kept but marked deprecated — they are now always empty - new useOverflowSnapshot hook subscribes via useState + useIsomorphicLayoutEffect - useOverflowCount, useIsOverflowItemVisible, useIsOverflowGroupVisible and useOverflowVisibility are rewritten on top of useOverflowSnapshot - Overflow.tsx no longer keeps a useState; onOverflowChange callers receive the same OnOverflowChangeData shape derived from the snapshot - drop the @fluentui/react-context-selector dependency from the package Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d7cec2e commit e8fe764

20 files changed

Lines changed: 292 additions & 139 deletions
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: OverflowManager now exposes getSnapshot and subscribe so consumers can subscribe directly to overflow state without forcing intermediate re-renders.",
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": "patch",
3+
"comment": "fix: each overflow hook (useOverflowCount, useIsOverflowItemVisible, useIsOverflowGroupVisible, useOverflowVisibility) now subscribes to the manager snapshot directly so the Overflow container no longer holds intermediate state. itemVisibility, groupVisibility and hasOverflow on the context are kept for backward compatibility but are now deprecated.",
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: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,13 @@ export interface OverflowManager {
6868
addOverflowMenu: (element: HTMLElement) => void;
6969
disconnect: () => void;
7070
forceUpdate: () => void;
71+
getSnapshot: () => OverflowEventPayload;
7172
observe: (container: HTMLElement, options?: ObserveOptions) => void;
7273
removeDivider: (groupId: string) => void;
7374
removeItem: (itemId: string) => void;
7475
removeOverflowMenu: () => void;
7576
setOptions: (options: Partial<ObserveOptions>) => void;
77+
subscribe: (listener: () => void) => () => void;
7678
update: () => void;
7779
}
7880

Lines changed: 49 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { createOverflowManager } from './overflowManager';
2-
import type { ObserveOptions, OverflowEventPayload } from './types';
2+
import type { ObserveOptions } from './types';
33

44
describe('overflowManager', () => {
55
beforeAll(() => {
@@ -45,13 +45,20 @@ describe('overflowManager', () => {
4545
...options,
4646
});
4747

48-
const lastDispatch = (onUpdateOverflow: jest.Mock): OverflowEventPayload =>
49-
onUpdateOverflow.mock.calls[onUpdateOverflow.mock.calls.length - 1][0];
48+
const getVisibleIds = (manager: ReturnType<typeof createOverflowManager>) =>
49+
manager
50+
.getSnapshot()
51+
.visibleItems.map(item => item.id)
52+
.sort();
5053

51-
it('should dispatch overflow update after forceUpdate', () => {
52-
const onUpdateOverflow = jest.fn();
53-
const options = createObserveOptions({ onUpdateOverflow });
54-
const manager = createOverflowManager(options);
54+
const getInvisibleIds = (manager: ReturnType<typeof createOverflowManager>) =>
55+
manager
56+
.getSnapshot()
57+
.invisibleItems.map(item => item.id)
58+
.sort();
59+
60+
it('should expose a stable snapshot after forceUpdate', () => {
61+
const manager = createOverflowManager(createObserveOptions());
5562
const container = createContainer(100);
5663
const itemA = createElementWithSize('button', 40);
5764
const itemB = createElementWithSize('button', 40);
@@ -63,93 +70,90 @@ describe('overflowManager', () => {
6370
manager.observe(container);
6471
manager.forceUpdate();
6572

66-
const dispatch = lastDispatch(onUpdateOverflow);
67-
expect(dispatch.visibleItems.map(item => item.id).sort()).toEqual(['a', 'b']);
68-
expect(dispatch.invisibleItems).toEqual([]);
69-
expect(dispatch.groupVisibility).toEqual({});
73+
expect(getVisibleIds(manager)).toEqual(['a', 'b']);
74+
expect(getInvisibleIds(manager)).toEqual([]);
75+
expect(manager.getSnapshot().groupVisibility).toEqual({});
7076
});
7177

72-
it('should re-dispatch when setOptions changes a relevant option', () => {
73-
const onUpdateOverflow = jest.fn();
74-
const options = createObserveOptions({ onUpdateOverflow });
75-
const manager = createOverflowManager(options);
78+
it('should update snapshot and notify subscribers when options change', () => {
79+
const manager = createOverflowManager(createObserveOptions());
7680
const container = createContainer(100);
7781
const itemA = createElementWithSize('button', 40);
7882
const itemB = createElementWithSize('button', 40);
7983
const menu = createElementWithSize('button', 20);
84+
const listener = jest.fn();
8085

8186
manager.addItem({ element: itemA, id: 'a', priority: 1 });
8287
manager.addItem({ element: itemB, id: 'b', priority: 0 });
8388
manager.addOverflowMenu(menu);
8489
manager.observe(container);
8590
manager.forceUpdate();
91+
const unsubscribe = manager.subscribe(listener);
8692

87-
onUpdateOverflow.mockClear();
8893
manager.setOptions({ padding: 30 });
8994

90-
expect(onUpdateOverflow).toHaveBeenCalled();
91-
const dispatch = lastDispatch(onUpdateOverflow);
92-
expect(dispatch.visibleItems.map(item => item.id)).toEqual(['a']);
93-
expect(dispatch.invisibleItems.map(item => item.id)).toEqual(['b']);
95+
expect(listener).toHaveBeenCalled();
96+
expect(getVisibleIds(manager)).toEqual(['a']);
97+
expect(getInvisibleIds(manager)).toEqual(['b']);
98+
expect(manager.getSnapshot().groupVisibility).toEqual({});
99+
100+
unsubscribe();
94101
});
95102

96-
it('should not re-dispatch when setOptions is called with a partial that does not change anything', () => {
97-
const onUpdateOverflow = jest.fn();
98-
const options = createObserveOptions({ onUpdateOverflow });
99-
const manager = createOverflowManager(options);
103+
it('should not notify subscribers when setOptions is called with a partial that does not change anything', () => {
104+
const manager = createOverflowManager(createObserveOptions());
100105
const container = createContainer(100);
101106
const itemA = createElementWithSize('button', 40);
102107

103108
manager.addItem({ element: itemA, id: 'a', priority: 1 });
104-
manager.observe(container, options);
109+
manager.observe(container);
105110
manager.forceUpdate();
106111

107-
onUpdateOverflow.mockClear();
112+
const listener = jest.fn();
113+
manager.subscribe(listener);
108114
manager.setOptions({ padding: 10 }); // padding is already 10; no real change
109115

110-
expect(onUpdateOverflow).not.toHaveBeenCalled();
116+
expect(listener).not.toHaveBeenCalled();
111117
});
112118

113-
it('disconnect stops observation and re-observe restarts dispatching', () => {
114-
const onUpdateOverflow = jest.fn();
115-
const options = createObserveOptions({ onUpdateOverflow });
116-
const manager = createOverflowManager(options);
119+
it('should reset snapshot state when disconnect runs', () => {
120+
const manager = createOverflowManager(createObserveOptions());
117121
const container = createContainer(100);
118122
const item = createElementWithSize('button', 40);
119123

120124
manager.addItem({ element: item, id: 'a', priority: 1 });
121125
manager.observe(container);
122126
manager.forceUpdate();
123-
expect(lastDispatch(onUpdateOverflow).visibleItems.map(i => i.id)).toEqual(['a']);
127+
128+
expect(getVisibleIds(manager)).toEqual(['a']);
124129

125130
manager.disconnect();
126-
onUpdateOverflow.mockClear();
127131

128-
manager.addItem({ element: item, id: 'a', priority: 1 });
129-
manager.observe(container);
130-
manager.forceUpdate();
131-
expect(onUpdateOverflow).toHaveBeenCalled();
132-
expect(lastDispatch(onUpdateOverflow).visibleItems.map(i => i.id)).toEqual(['a']);
132+
expect(manager.getSnapshot()).toEqual({
133+
visibleItems: [],
134+
invisibleItems: [],
135+
groupVisibility: {},
136+
});
133137
});
134138

135139
it('should remove items through removeItem', () => {
136-
const onUpdateOverflow = jest.fn();
137-
const options = createObserveOptions({ onUpdateOverflow });
138-
const manager = createOverflowManager(options);
140+
const manager = createOverflowManager(createObserveOptions());
139141
const container = createContainer(100);
140142
const item = createElementWithSize('button', 40);
141143

142144
manager.addItem({ element: item, id: 'a', priority: 1 });
143145
manager.observe(container);
144146
manager.forceUpdate();
145147

146-
expect(lastDispatch(onUpdateOverflow).visibleItems.map(i => i.id)).toEqual(['a']);
148+
expect(getVisibleIds(manager)).toEqual(['a']);
147149

148150
manager.removeItem('a');
149151
manager.forceUpdate();
150152

151-
const dispatch = lastDispatch(onUpdateOverflow);
152-
expect(dispatch.visibleItems).toEqual([]);
153-
expect(dispatch.invisibleItems).toEqual([]);
153+
expect(manager.getSnapshot()).toEqual({
154+
visibleItems: [],
155+
invisibleItems: [],
156+
groupVisibility: {},
157+
});
154158
});
155159
});

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

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99
OverflowManager,
1010
ObserveOptions,
1111
OverflowDividerEntry,
12+
OverflowEventPayload,
1213
} from './types';
1314

1415
const DEFAULT_OPTIONS: Required<ObserveOptions> = {
@@ -46,10 +47,22 @@ export function createOverflowManager(initialOptions: Partial<ObserveOptions> =
4647
const options: Required<ObserveOptions> = { ...DEFAULT_OPTIONS, ...initialOptions };
4748
const overflowItems: Record<string, OverflowItemEntry> = {};
4849
const overflowDividers: Record<string, OverflowDividerEntry> = {};
50+
const listeners = new Set<() => void>();
4951
let disposeResizeObserver: () => void = () => {
5052
/* noop */
5153
};
5254

55+
let snapshot: OverflowEventPayload = {
56+
visibleItems: [],
57+
invisibleItems: [],
58+
groupVisibility: {},
59+
};
60+
const takeSnapshot = (nextSnapshot: OverflowEventPayload) => {
61+
snapshot = nextSnapshot;
62+
options.onUpdateOverflow(snapshot);
63+
listeners.forEach(listener => listener());
64+
};
65+
5366
const getNextItem = (queueToDequeue: PriorityQueue<string>, queueToEnqueue: PriorityQueue<string>) => {
5467
const nextItem = queueToDequeue.dequeue();
5568
queueToEnqueue.enqueue(nextItem);
@@ -159,9 +172,15 @@ export function createOverflowManager(initialOptions: Partial<ObserveOptions> =
159172
const visibleItems = visibleItemIds.map(itemId => overflowItems[itemId]);
160173
const invisibleItems = invisibleItemIds.map(itemId => overflowItems[itemId]);
161174

162-
options.onUpdateOverflow({ visibleItems, invisibleItems, groupVisibility: groupManager.groupVisibility() });
175+
takeSnapshot({
176+
visibleItems,
177+
invisibleItems,
178+
groupVisibility: groupManager.groupVisibility(),
179+
});
163180
};
164181

182+
const getSnapshot: OverflowManager['getSnapshot'] = () => snapshot;
183+
165184
const processOverflowItems = (): boolean => {
166185
if (!container) {
167186
return false;
@@ -271,6 +290,13 @@ export function createOverflowManager(initialOptions: Partial<ObserveOptions> =
271290
Object.keys(overflowDividers).forEach(dividerId => removeDivider(dividerId));
272291
removeOverflowMenu();
273292
sizeCache.clear();
293+
294+
// notify subscribers that the manager is no longer tracking anything
295+
takeSnapshot({
296+
visibleItems: [],
297+
invisibleItems: [],
298+
groupVisibility: {},
299+
});
274300
};
275301

276302
const addItem: OverflowManager['addItem'] = items => {
@@ -351,6 +377,14 @@ export function createOverflowManager(initialOptions: Partial<ObserveOptions> =
351377
}
352378
};
353379

380+
const subscribe: OverflowManager['subscribe'] = listener => {
381+
listeners.add(listener);
382+
383+
return () => {
384+
listeners.delete(listener);
385+
};
386+
};
387+
354388
return {
355389
addItem,
356390
disconnect,
@@ -363,6 +397,8 @@ export function createOverflowManager(initialOptions: Partial<ObserveOptions> =
363397
addDivider,
364398
removeDivider,
365399
setOptions,
400+
getSnapshot,
401+
subscribe,
366402
};
367403
}
368404

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,4 +204,14 @@ export interface OverflowManager {
204204
* Unsets the overflow menu element
205205
*/
206206
removeOverflowMenu: () => void;
207+
208+
/**
209+
* Returns the current canonical overflow snapshot.
210+
*/
211+
getSnapshot: () => OverflowEventPayload;
212+
213+
/**
214+
* Subscribes to snapshot changes.
215+
*/
216+
subscribe: (listener: () => void) => () => void;
207217
}

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

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

7-
import type { ContextSelector } from '@fluentui/react-context-selector';
87
import type { ObserveOptions } from '@fluentui/priority-overflow';
98
import type { OnUpdateOverflow } from '@fluentui/priority-overflow';
109
import type { OverflowDividerEntry } from '@fluentui/priority-overflow';
11-
import { OverflowGroupState } from '@fluentui/priority-overflow';
10+
import type { OverflowEventPayload } from '@fluentui/priority-overflow';
11+
import type { OverflowGroupState } from '@fluentui/priority-overflow';
1212
import type { OverflowItemEntry } from '@fluentui/priority-overflow';
1313
import * as React_2 from 'react';
1414

@@ -72,12 +72,15 @@ export function useIsOverflowItemVisible(id: string): boolean;
7272
export const useOverflowContainer: <TElement extends HTMLElement>(update: OnUpdateOverflow, options: Omit<ObserveOptions, "onUpdateOverflow">) => UseOverflowContainerReturn<TElement>;
7373

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

7979
// @internal (undocumented)
80-
export const useOverflowContext: <SelectedValue>(selector: ContextSelector<OverflowContextValue, SelectedValue>) => SelectedValue;
80+
export function useOverflowContext(): OverflowContextValue;
81+
82+
// @internal (undocumented)
83+
export function useOverflowContext<SelectedValue>(selector: (context: OverflowContextValue) => SelectedValue): SelectedValue;
8184

8285
// @public (undocumented)
8386
export const useOverflowCount: () => number;

packages/react-components/react-overflow/library/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
"license": "MIT",
1414
"dependencies": {
1515
"@fluentui/priority-overflow": "^9.3.0",
16-
"@fluentui/react-context-selector": "^9.2.17",
1716
"@fluentui/react-shared-contexts": "^9.26.2",
1817
"@fluentui/react-theme": "^9.2.1",
1918
"@fluentui/react-utilities": "^9.26.4",

packages/react-components/react-overflow/library/src/Overflow.cy.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
OverflowReorderObserver,
88
useIsOverflowGroupVisible,
99
useOverflowMenu,
10-
useOverflowContext,
10+
useOverflowVisibility,
1111
type OverflowProps,
1212
type OverflowItemProps,
1313
type OnOverflowChangeData,
@@ -96,7 +96,7 @@ const Item = ({ children, width, ...overflowItemProps }: ItemProps) => {
9696

9797
const Menu: React.FC<{ width?: number }> = ({ width }) => {
9898
const { isOverflowing, ref, overflowCount } = useOverflowMenu<HTMLButtonElement>();
99-
const itemVisibility = useOverflowContext(ctx => ctx.itemVisibility);
99+
const { itemVisibility } = useOverflowVisibility();
100100
const selector = {
101101
[selectors.menu]: '',
102102
};

0 commit comments

Comments
 (0)