From e8fe764d06e6f2738057464296b90c566c8c2e05 Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Thu, 28 May 2026 12:33:22 +0200 Subject: [PATCH 1/3] refactor(react-overflow,priority-overflow): subscribe model removes intermediate state from Overflow container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ...entui-priority-overflow-pr2-subscribe.json | 7 ++ ...fluentui-react-overflow-pr2-subscribe.json | 7 ++ .../etc/priority-overflow.api.md | 2 + .../src/overflowManager.test.ts | 94 ++++++++++--------- .../priority-overflow/src/overflowManager.ts | 38 +++++++- .../priority-overflow/src/types.ts | 10 ++ .../library/etc/react-overflow.api.md | 11 ++- .../react-overflow/library/package.json | 1 - .../library/src/Overflow.cy.tsx | 4 +- .../library/src/components/Overflow.tsx | 90 +++++++++--------- .../library/src/overflowContext.ts | 50 ++++++++-- .../react-overflow/library/src/types.ts | 5 +- .../library/src/useIsOverflowGroupVisible.ts | 4 +- .../library/src/useIsOverflowItemVisible.ts | 4 +- .../library/src/useOverflowContainer.test.tsx | 2 + .../library/src/useOverflowContainer.ts | 18 ++++ .../library/src/useOverflowCount.ts | 13 +-- .../library/src/useOverflowSnapshot.ts | 17 ++++ .../src/useOverflowVisibility.test.tsx | 24 +++-- .../library/src/useOverflowVisibility.ts | 30 ++++-- 20 files changed, 292 insertions(+), 139 deletions(-) create mode 100644 change/@fluentui-priority-overflow-pr2-subscribe.json create mode 100644 change/@fluentui-react-overflow-pr2-subscribe.json create mode 100644 packages/react-components/react-overflow/library/src/useOverflowSnapshot.ts diff --git a/change/@fluentui-priority-overflow-pr2-subscribe.json b/change/@fluentui-priority-overflow-pr2-subscribe.json new file mode 100644 index 00000000000000..b1ff950f2bb98d --- /dev/null +++ b/change/@fluentui-priority-overflow-pr2-subscribe.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: OverflowManager now exposes getSnapshot and subscribe so consumers can subscribe directly to overflow state without forcing intermediate re-renders.", + "packageName": "@fluentui/priority-overflow", + "email": "bsunderhus@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-overflow-pr2-subscribe.json b/change/@fluentui-react-overflow-pr2-subscribe.json new file mode 100644 index 00000000000000..22a4a3c193bbcb --- /dev/null +++ b/change/@fluentui-react-overflow-pr2-subscribe.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "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.", + "packageName": "@fluentui/react-overflow", + "email": "bsunderhus@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/priority-overflow/etc/priority-overflow.api.md b/packages/react-components/priority-overflow/etc/priority-overflow.api.md index 2a720d068daf9e..7186ac842abd47 100644 --- a/packages/react-components/priority-overflow/etc/priority-overflow.api.md +++ b/packages/react-components/priority-overflow/etc/priority-overflow.api.md @@ -68,11 +68,13 @@ export interface OverflowManager { addOverflowMenu: (element: HTMLElement) => void; disconnect: () => void; forceUpdate: () => void; + getSnapshot: () => OverflowEventPayload; observe: (container: HTMLElement, options?: ObserveOptions) => void; removeDivider: (groupId: string) => void; removeItem: (itemId: string) => void; removeOverflowMenu: () => void; setOptions: (options: Partial) => void; + subscribe: (listener: () => void) => () => void; update: () => void; } diff --git a/packages/react-components/priority-overflow/src/overflowManager.test.ts b/packages/react-components/priority-overflow/src/overflowManager.test.ts index d937f4615f9ff1..8e33bde836bf98 100644 --- a/packages/react-components/priority-overflow/src/overflowManager.test.ts +++ b/packages/react-components/priority-overflow/src/overflowManager.test.ts @@ -1,5 +1,5 @@ import { createOverflowManager } from './overflowManager'; -import type { ObserveOptions, OverflowEventPayload } from './types'; +import type { ObserveOptions } from './types'; describe('overflowManager', () => { beforeAll(() => { @@ -45,13 +45,20 @@ describe('overflowManager', () => { ...options, }); - const lastDispatch = (onUpdateOverflow: jest.Mock): OverflowEventPayload => - onUpdateOverflow.mock.calls[onUpdateOverflow.mock.calls.length - 1][0]; + const getVisibleIds = (manager: ReturnType) => + manager + .getSnapshot() + .visibleItems.map(item => item.id) + .sort(); - it('should dispatch overflow update after forceUpdate', () => { - const onUpdateOverflow = jest.fn(); - const options = createObserveOptions({ onUpdateOverflow }); - const manager = createOverflowManager(options); + const getInvisibleIds = (manager: ReturnType) => + manager + .getSnapshot() + .invisibleItems.map(item => item.id) + .sort(); + + it('should expose a stable snapshot after forceUpdate', () => { + const manager = createOverflowManager(createObserveOptions()); const container = createContainer(100); const itemA = createElementWithSize('button', 40); const itemB = createElementWithSize('button', 40); @@ -63,79 +70,74 @@ describe('overflowManager', () => { manager.observe(container); manager.forceUpdate(); - const dispatch = lastDispatch(onUpdateOverflow); - expect(dispatch.visibleItems.map(item => item.id).sort()).toEqual(['a', 'b']); - expect(dispatch.invisibleItems).toEqual([]); - expect(dispatch.groupVisibility).toEqual({}); + expect(getVisibleIds(manager)).toEqual(['a', 'b']); + expect(getInvisibleIds(manager)).toEqual([]); + expect(manager.getSnapshot().groupVisibility).toEqual({}); }); - it('should re-dispatch when setOptions changes a relevant option', () => { - const onUpdateOverflow = jest.fn(); - const options = createObserveOptions({ onUpdateOverflow }); - const manager = createOverflowManager(options); + it('should update snapshot and notify subscribers when options change', () => { + const manager = createOverflowManager(createObserveOptions()); const container = createContainer(100); const itemA = createElementWithSize('button', 40); const itemB = createElementWithSize('button', 40); const menu = createElementWithSize('button', 20); + const listener = jest.fn(); manager.addItem({ element: itemA, id: 'a', priority: 1 }); manager.addItem({ element: itemB, id: 'b', priority: 0 }); manager.addOverflowMenu(menu); manager.observe(container); manager.forceUpdate(); + const unsubscribe = manager.subscribe(listener); - onUpdateOverflow.mockClear(); manager.setOptions({ padding: 30 }); - expect(onUpdateOverflow).toHaveBeenCalled(); - const dispatch = lastDispatch(onUpdateOverflow); - expect(dispatch.visibleItems.map(item => item.id)).toEqual(['a']); - expect(dispatch.invisibleItems.map(item => item.id)).toEqual(['b']); + expect(listener).toHaveBeenCalled(); + expect(getVisibleIds(manager)).toEqual(['a']); + expect(getInvisibleIds(manager)).toEqual(['b']); + expect(manager.getSnapshot().groupVisibility).toEqual({}); + + unsubscribe(); }); - it('should not re-dispatch when setOptions is called with a partial that does not change anything', () => { - const onUpdateOverflow = jest.fn(); - const options = createObserveOptions({ onUpdateOverflow }); - const manager = createOverflowManager(options); + it('should not notify subscribers when setOptions is called with a partial that does not change anything', () => { + const manager = createOverflowManager(createObserveOptions()); const container = createContainer(100); const itemA = createElementWithSize('button', 40); manager.addItem({ element: itemA, id: 'a', priority: 1 }); - manager.observe(container, options); + manager.observe(container); manager.forceUpdate(); - onUpdateOverflow.mockClear(); + const listener = jest.fn(); + manager.subscribe(listener); manager.setOptions({ padding: 10 }); // padding is already 10; no real change - expect(onUpdateOverflow).not.toHaveBeenCalled(); + expect(listener).not.toHaveBeenCalled(); }); - it('disconnect stops observation and re-observe restarts dispatching', () => { - const onUpdateOverflow = jest.fn(); - const options = createObserveOptions({ onUpdateOverflow }); - const manager = createOverflowManager(options); + it('should reset snapshot state when disconnect runs', () => { + const manager = createOverflowManager(createObserveOptions()); const container = createContainer(100); const item = createElementWithSize('button', 40); manager.addItem({ element: item, id: 'a', priority: 1 }); manager.observe(container); manager.forceUpdate(); - expect(lastDispatch(onUpdateOverflow).visibleItems.map(i => i.id)).toEqual(['a']); + + expect(getVisibleIds(manager)).toEqual(['a']); manager.disconnect(); - onUpdateOverflow.mockClear(); - manager.addItem({ element: item, id: 'a', priority: 1 }); - manager.observe(container); - manager.forceUpdate(); - expect(onUpdateOverflow).toHaveBeenCalled(); - expect(lastDispatch(onUpdateOverflow).visibleItems.map(i => i.id)).toEqual(['a']); + expect(manager.getSnapshot()).toEqual({ + visibleItems: [], + invisibleItems: [], + groupVisibility: {}, + }); }); it('should remove items through removeItem', () => { - const onUpdateOverflow = jest.fn(); - const options = createObserveOptions({ onUpdateOverflow }); - const manager = createOverflowManager(options); + const manager = createOverflowManager(createObserveOptions()); const container = createContainer(100); const item = createElementWithSize('button', 40); @@ -143,13 +145,15 @@ describe('overflowManager', () => { manager.observe(container); manager.forceUpdate(); - expect(lastDispatch(onUpdateOverflow).visibleItems.map(i => i.id)).toEqual(['a']); + expect(getVisibleIds(manager)).toEqual(['a']); manager.removeItem('a'); manager.forceUpdate(); - const dispatch = lastDispatch(onUpdateOverflow); - expect(dispatch.visibleItems).toEqual([]); - expect(dispatch.invisibleItems).toEqual([]); + expect(manager.getSnapshot()).toEqual({ + visibleItems: [], + invisibleItems: [], + groupVisibility: {}, + }); }); }); diff --git a/packages/react-components/priority-overflow/src/overflowManager.ts b/packages/react-components/priority-overflow/src/overflowManager.ts index c41f43617cb099..bf288a5548dc9b 100644 --- a/packages/react-components/priority-overflow/src/overflowManager.ts +++ b/packages/react-components/priority-overflow/src/overflowManager.ts @@ -9,6 +9,7 @@ import type { OverflowManager, ObserveOptions, OverflowDividerEntry, + OverflowEventPayload, } from './types'; const DEFAULT_OPTIONS: Required = { @@ -46,10 +47,22 @@ export function createOverflowManager(initialOptions: Partial = const options: Required = { ...DEFAULT_OPTIONS, ...initialOptions }; const overflowItems: Record = {}; const overflowDividers: Record = {}; + const listeners = new Set<() => void>(); let disposeResizeObserver: () => void = () => { /* noop */ }; + let snapshot: OverflowEventPayload = { + visibleItems: [], + invisibleItems: [], + groupVisibility: {}, + }; + const takeSnapshot = (nextSnapshot: OverflowEventPayload) => { + snapshot = nextSnapshot; + options.onUpdateOverflow(snapshot); + listeners.forEach(listener => listener()); + }; + const getNextItem = (queueToDequeue: PriorityQueue, queueToEnqueue: PriorityQueue) => { const nextItem = queueToDequeue.dequeue(); queueToEnqueue.enqueue(nextItem); @@ -159,9 +172,15 @@ export function createOverflowManager(initialOptions: Partial = const visibleItems = visibleItemIds.map(itemId => overflowItems[itemId]); const invisibleItems = invisibleItemIds.map(itemId => overflowItems[itemId]); - options.onUpdateOverflow({ visibleItems, invisibleItems, groupVisibility: groupManager.groupVisibility() }); + takeSnapshot({ + visibleItems, + invisibleItems, + groupVisibility: groupManager.groupVisibility(), + }); }; + const getSnapshot: OverflowManager['getSnapshot'] = () => snapshot; + const processOverflowItems = (): boolean => { if (!container) { return false; @@ -271,6 +290,13 @@ export function createOverflowManager(initialOptions: Partial = Object.keys(overflowDividers).forEach(dividerId => removeDivider(dividerId)); removeOverflowMenu(); sizeCache.clear(); + + // notify subscribers that the manager is no longer tracking anything + takeSnapshot({ + visibleItems: [], + invisibleItems: [], + groupVisibility: {}, + }); }; const addItem: OverflowManager['addItem'] = items => { @@ -351,6 +377,14 @@ export function createOverflowManager(initialOptions: Partial = } }; + const subscribe: OverflowManager['subscribe'] = listener => { + listeners.add(listener); + + return () => { + listeners.delete(listener); + }; + }; + return { addItem, disconnect, @@ -363,6 +397,8 @@ export function createOverflowManager(initialOptions: Partial = addDivider, removeDivider, setOptions, + getSnapshot, + subscribe, }; } diff --git a/packages/react-components/priority-overflow/src/types.ts b/packages/react-components/priority-overflow/src/types.ts index 51faa5a5ee4fd7..bf4bda0d5b08cf 100644 --- a/packages/react-components/priority-overflow/src/types.ts +++ b/packages/react-components/priority-overflow/src/types.ts @@ -204,4 +204,14 @@ export interface OverflowManager { * Unsets the overflow menu element */ removeOverflowMenu: () => void; + + /** + * Returns the current canonical overflow snapshot. + */ + getSnapshot: () => OverflowEventPayload; + + /** + * Subscribes to snapshot changes. + */ + subscribe: (listener: () => void) => () => void; } diff --git a/packages/react-components/react-overflow/library/etc/react-overflow.api.md b/packages/react-components/react-overflow/library/etc/react-overflow.api.md index 4c435fc9a02e79..f9a9c4759d7c83 100644 --- a/packages/react-components/react-overflow/library/etc/react-overflow.api.md +++ b/packages/react-components/react-overflow/library/etc/react-overflow.api.md @@ -4,11 +4,11 @@ ```ts -import type { ContextSelector } from '@fluentui/react-context-selector'; import type { ObserveOptions } from '@fluentui/priority-overflow'; import type { OnUpdateOverflow } from '@fluentui/priority-overflow'; import type { OverflowDividerEntry } from '@fluentui/priority-overflow'; -import { OverflowGroupState } from '@fluentui/priority-overflow'; +import type { OverflowEventPayload } from '@fluentui/priority-overflow'; +import type { OverflowGroupState } from '@fluentui/priority-overflow'; import type { OverflowItemEntry } from '@fluentui/priority-overflow'; import * as React_2 from 'react'; @@ -72,12 +72,15 @@ export function useIsOverflowItemVisible(id: string): boolean; export const useOverflowContainer: (update: OnUpdateOverflow, options: Omit) => UseOverflowContainerReturn; // @internal (undocumented) -export interface UseOverflowContainerReturn extends Pick { +export interface UseOverflowContainerReturn extends Pick { containerRef: React_2.RefObject; } // @internal (undocumented) -export const useOverflowContext: (selector: ContextSelector) => SelectedValue; +export function useOverflowContext(): OverflowContextValue; + +// @internal (undocumented) +export function useOverflowContext(selector: (context: OverflowContextValue) => SelectedValue): SelectedValue; // @public (undocumented) export const useOverflowCount: () => number; diff --git a/packages/react-components/react-overflow/library/package.json b/packages/react-components/react-overflow/library/package.json index 3fe0edb5f8ac3c..8306f8188439ea 100644 --- a/packages/react-components/react-overflow/library/package.json +++ b/packages/react-components/react-overflow/library/package.json @@ -13,7 +13,6 @@ "license": "MIT", "dependencies": { "@fluentui/priority-overflow": "^9.3.0", - "@fluentui/react-context-selector": "^9.2.17", "@fluentui/react-shared-contexts": "^9.26.2", "@fluentui/react-theme": "^9.2.1", "@fluentui/react-utilities": "^9.26.4", diff --git a/packages/react-components/react-overflow/library/src/Overflow.cy.tsx b/packages/react-components/react-overflow/library/src/Overflow.cy.tsx index f4c15e969796ab..faa43c29313665 100644 --- a/packages/react-components/react-overflow/library/src/Overflow.cy.tsx +++ b/packages/react-components/react-overflow/library/src/Overflow.cy.tsx @@ -7,7 +7,7 @@ import { OverflowReorderObserver, useIsOverflowGroupVisible, useOverflowMenu, - useOverflowContext, + useOverflowVisibility, type OverflowProps, type OverflowItemProps, type OnOverflowChangeData, @@ -96,7 +96,7 @@ const Item = ({ children, width, ...overflowItemProps }: ItemProps) => { const Menu: React.FC<{ width?: number }> = ({ width }) => { const { isOverflowing, ref, overflowCount } = useOverflowMenu(); - const itemVisibility = useOverflowContext(ctx => ctx.itemVisibility); + const { itemVisibility } = useOverflowVisibility(); const selector = { [selectors.menu]: '', }; diff --git a/packages/react-components/react-overflow/library/src/components/Overflow.tsx b/packages/react-components/react-overflow/library/src/components/Overflow.tsx index 85563028ad8967..66a65c34e9546b 100644 --- a/packages/react-components/react-overflow/library/src/components/Overflow.tsx +++ b/packages/react-components/react-overflow/library/src/components/Overflow.tsx @@ -2,7 +2,12 @@ import * as React from 'react'; import { mergeClasses } from '@griffel/react'; -import type { OnUpdateOverflow, OverflowGroupState, ObserveOptions } from '@fluentui/priority-overflow'; +import type { + ObserveOptions, + OnUpdateOverflow, + OverflowEventPayload, + OverflowGroupState, +} from '@fluentui/priority-overflow'; import { applyTriggerPropsToChildren, getTriggerChild, @@ -10,7 +15,7 @@ import { useMergedRefs, } from '@fluentui/react-utilities'; -import { OverflowContext } from '../overflowContext'; +import { OverflowContext, type OverflowContextValue } from '../overflowContext'; import { updateVisibilityAttribute, useOverflowContainer } from '../useOverflowContainer'; import { useOverflowStyles } from './useOverflowStyles.styles'; @@ -51,42 +56,22 @@ export const Overflow = React.forwardRef((props: OverflowProps, ref) => { hasHiddenItems, } = props; - const [overflowState, setOverflowState] = React.useState({ - hasOverflow: false, - itemVisibility: {}, - groupVisibility: {}, - }); - - // useOverflowContainer wraps this method in a useEventCallback. const update: OnUpdateOverflow = data => { - const { visibleItems, invisibleItems, groupVisibility } = data; - - const itemVisibility: Record = {}; - visibleItems.forEach(item => { - itemVisibility[item.id] = true; - }); - invisibleItems.forEach(x => (itemVisibility[x.id] = false)); - const newState = { - hasOverflow: data.invisibleItems.length > 0, - itemVisibility, - groupVisibility, - }; - onOverflowChange?.(null, { ...newState }); - - setOverflowState(newState); + if (!onOverflowChange) { + return; + } + onOverflowChange(null, _overflowPayloadToState(data)); }; - const { containerRef, registerItem, updateOverflow, registerOverflowMenu, registerDivider } = useOverflowContainer( - update, - { + const { containerRef, getSnapshot, subscribe, registerItem, updateOverflow, registerOverflowMenu, registerDivider } = + useOverflowContainer(update, { overflowDirection, overflowAxis, padding, minimumVisible, hasHiddenItems, onUpdateItemVisibility: updateVisibilityAttribute, - }, - ); + }); const child = getTriggerChild(children); const clonedChild = applyTriggerPropsToChildren(children, { @@ -94,20 +79,37 @@ export const Overflow = React.forwardRef((props: OverflowProps, ref) => { className: mergeClasses('fui-Overflow', styles.overflowMenu, styles.overflowingItems, child?.props.className), }); - return ( - - {clonedChild} - + const ctx: OverflowContextValue = React.useMemo( + () => ({ + groupVisibility: {}, + itemVisibility: {}, + hasOverflow: false, + registerItem, + updateOverflow, + registerOverflowMenu, + registerDivider, + containerRef, + getSnapshot, + subscribe, + }), + [getSnapshot, subscribe, registerItem, updateOverflow, registerOverflowMenu, registerDivider, containerRef], ); + + return {clonedChild}; }); + +/** + * @internal + */ +export const _overflowPayloadToState = (data: OverflowEventPayload): OverflowState => { + const itemVisibility: Record = {}; + data.visibleItems.forEach(item => { + itemVisibility[item.id] = true; + }); + data.invisibleItems.forEach(x => (itemVisibility[x.id] = false)); + return { + itemVisibility, + groupVisibility: data.groupVisibility, + hasOverflow: data.invisibleItems.length > 0, + }; +}; diff --git a/packages/react-components/react-overflow/library/src/overflowContext.ts b/packages/react-components/react-overflow/library/src/overflowContext.ts index 3cbe5d28c24430..d8b1ac7716518b 100644 --- a/packages/react-components/react-overflow/library/src/overflowContext.ts +++ b/packages/react-components/react-overflow/library/src/overflowContext.ts @@ -1,41 +1,71 @@ 'use client'; -import type * as React from 'react'; -import type { OverflowGroupState, OverflowItemEntry, OverflowDividerEntry } from '@fluentui/priority-overflow'; -import type { ContextSelector, Context } from '@fluentui/react-context-selector'; -import { createContext, useContextSelector } from '@fluentui/react-context-selector'; +import type { + OverflowItemEntry, + OverflowDividerEntry, + OverflowGroupState, + OverflowEventPayload, +} from '@fluentui/priority-overflow'; +import * as React from 'react'; /** * @internal */ export interface OverflowContextValue { + /** + * @deprecated This value is not guaranteed to be up to date and should not be used directly. Use getSnapshot or the provided hooks instead + */ itemVisibility: Record; + /** + * @deprecated This value is not guaranteed to be up to date and should not be used directly. Use getSnapshot or the provided hooks instead + */ groupVisibility: Record; + /** + * @deprecated This value is not guaranteed to be up to date and should not be used directly. Use getSnapshot or the provided hooks instead + */ hasOverflow: boolean; registerItem: (item: OverflowItemEntry) => () => void; registerOverflowMenu: (el: HTMLElement) => () => void; registerDivider: (divider: OverflowDividerEntry) => () => void; updateOverflow: (padding?: number) => void; containerRef?: React.RefObject; + getSnapshot: () => OverflowEventPayload; + subscribe: (listener: () => void) => () => void; } -export const OverflowContext = createContext( +export const OverflowContext = React.createContext( undefined, -) as Context; +) as React.Context; const overflowContextDefaultValue: OverflowContextValue = { + hasOverflow: false, itemVisibility: {}, groupVisibility: {}, - hasOverflow: false, registerItem: () => () => null, updateOverflow: () => null, registerOverflowMenu: () => () => null, registerDivider: () => () => null, + getSnapshot: () => ({ + visibleItems: [], + invisibleItems: [], + groupVisibility: {}, + }), + subscribe: () => () => null, }; /** * @internal */ -export const useOverflowContext = ( - selector: ContextSelector, -): SelectedValue => useContextSelector(OverflowContext, (ctx = overflowContextDefaultValue) => selector(ctx)); +export function useOverflowContext(): OverflowContextValue; +/** + * @internal + */ +export function useOverflowContext( + selector: (context: OverflowContextValue) => SelectedValue, +): SelectedValue; +export function useOverflowContext( + selector?: (context: OverflowContextValue) => SelectedValue, +): SelectedValue | OverflowContextValue { + const context = React.useContext(OverflowContext) ?? overflowContextDefaultValue; + return selector ? selector(context) : context; +} diff --git a/packages/react-components/react-overflow/library/src/types.ts b/packages/react-components/react-overflow/library/src/types.ts index 22a2bccd3292f2..fadd260165a5b1 100644 --- a/packages/react-components/react-overflow/library/src/types.ts +++ b/packages/react-components/react-overflow/library/src/types.ts @@ -5,7 +5,10 @@ import type { OverflowContextValue } from './overflowContext'; * @internal */ export interface UseOverflowContainerReturn - extends Pick { + extends Pick< + OverflowContextValue, + 'registerItem' | 'updateOverflow' | 'registerOverflowMenu' | 'registerDivider' | 'getSnapshot' | 'subscribe' + > { /** * Ref to apply to the container that will overflow */ diff --git a/packages/react-components/react-overflow/library/src/useIsOverflowGroupVisible.ts b/packages/react-components/react-overflow/library/src/useIsOverflowGroupVisible.ts index a389d36fc46224..145bef2063fed2 100644 --- a/packages/react-components/react-overflow/library/src/useIsOverflowGroupVisible.ts +++ b/packages/react-components/react-overflow/library/src/useIsOverflowGroupVisible.ts @@ -1,12 +1,12 @@ 'use client'; import type { OverflowGroupState } from '@fluentui/priority-overflow'; -import { useOverflowContext } from './overflowContext'; +import { useOverflowSnapshot } from './useOverflowSnapshot'; /** * @param id - unique identifier for a group of overflow items * @returns visibility state of the group */ export function useIsOverflowGroupVisible(id: string): OverflowGroupState { - return useOverflowContext(ctx => ctx.groupVisibility[id]); + return useOverflowSnapshot().groupVisibility[id]; } diff --git a/packages/react-components/react-overflow/library/src/useIsOverflowItemVisible.ts b/packages/react-components/react-overflow/library/src/useIsOverflowItemVisible.ts index f23b26765de17f..f7e066af9fe976 100644 --- a/packages/react-components/react-overflow/library/src/useIsOverflowItemVisible.ts +++ b/packages/react-components/react-overflow/library/src/useIsOverflowItemVisible.ts @@ -1,11 +1,11 @@ 'use client'; -import { useOverflowContext } from './overflowContext'; +import { useOverflowSnapshot } from './useOverflowSnapshot'; /** * @param id - unique identifier for the item used by the overflow manager * @returns visibility state of an overflow item */ export function useIsOverflowItemVisible(id: string): boolean { - return !!useOverflowContext(ctx => ctx.itemVisibility[id]); + return useOverflowSnapshot().visibleItems.some(item => item.id === id); } diff --git a/packages/react-components/react-overflow/library/src/useOverflowContainer.test.tsx b/packages/react-components/react-overflow/library/src/useOverflowContainer.test.tsx index d6badfedd4d649..a48bef71294df8 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowContainer.test.tsx +++ b/packages/react-components/react-overflow/library/src/useOverflowContainer.test.tsx @@ -20,6 +20,8 @@ const mockOverflowManager = (options: Partial = {}) => { addDivider: jest.fn(), removeDivider: jest.fn(), setOptions: jest.fn(), + getSnapshot: jest.fn(() => ({ visibleItems: [], invisibleItems: [], groupVisibility: {} })), + subscribe: jest.fn(() => () => null), }; (createOverflowManager as jest.Mock).mockReturnValue({ ...defaultMock, diff --git a/packages/react-components/react-overflow/library/src/useOverflowContainer.ts b/packages/react-components/react-overflow/library/src/useOverflowContainer.ts index 54fcd01abe93ad..6f4146a5dcf5c5 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowContainer.ts +++ b/packages/react-components/react-overflow/library/src/useOverflowContainer.ts @@ -118,12 +118,24 @@ export const useOverflowContainer = ( managerRef.current?.update(); }, []); + const getSnapshot = React.useCallback( + () => managerRef.current?.getSnapshot() ?? defaultSnapshot, + [], + ); + + const subscribe = React.useCallback( + listener => managerRef.current?.subscribe(listener) ?? noop, + [], + ); + return { registerItem, registerDivider, registerOverflowMenu, updateOverflow, containerRef, + getSnapshot, + subscribe, }; }; @@ -131,6 +143,12 @@ const noop = () => { /* noop */ }; +const defaultSnapshot = { + visibleItems: [], + invisibleItems: [], + groupVisibility: {}, +}; + export const updateVisibilityAttribute: OnUpdateItemVisibility = ({ item, visible }) => { if (visible) { item.element.removeAttribute(DATA_OVERFLOWING); diff --git a/packages/react-components/react-overflow/library/src/useOverflowCount.ts b/packages/react-components/react-overflow/library/src/useOverflowCount.ts index 91fb75f14a7bc8..d57225462baf92 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowCount.ts +++ b/packages/react-components/react-overflow/library/src/useOverflowCount.ts @@ -1,17 +1,8 @@ 'use client'; -import { useOverflowContext } from './overflowContext'; +import { useOverflowSnapshot } from './useOverflowSnapshot'; /** * @returns Number of items that are overflowing */ -export const useOverflowCount = (): number => - useOverflowContext(v => { - return Object.entries(v.itemVisibility).reduce((acc, [id, visible]) => { - if (!visible) { - acc++; - } - - return acc; - }, 0); - }); +export const useOverflowCount = (): number => useOverflowSnapshot().invisibleItems.length; diff --git a/packages/react-components/react-overflow/library/src/useOverflowSnapshot.ts b/packages/react-components/react-overflow/library/src/useOverflowSnapshot.ts new file mode 100644 index 00000000000000..7747c53f69ad43 --- /dev/null +++ b/packages/react-components/react-overflow/library/src/useOverflowSnapshot.ts @@ -0,0 +1,17 @@ +'use client'; + +import type { OverflowEventPayload } from '@fluentui/priority-overflow'; +import * as React from 'react'; +import { useOverflowContext } from './overflowContext'; +import { useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; + +export const useOverflowSnapshot = (): OverflowEventPayload => { + const { getSnapshot, subscribe } = useOverflowContext(); + const [snapshot, setSnapshot] = React.useState(() => getSnapshot()); + useIsomorphicLayoutEffect(() => { + return subscribe(() => { + setSnapshot(getSnapshot()); + }); + }, [subscribe, getSnapshot]); + return snapshot; +}; diff --git a/packages/react-components/react-overflow/library/src/useOverflowVisibility.test.tsx b/packages/react-components/react-overflow/library/src/useOverflowVisibility.test.tsx index 508d0d1e8f3382..55a256891b41e7 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowVisibility.test.tsx +++ b/packages/react-components/react-overflow/library/src/useOverflowVisibility.test.tsx @@ -5,26 +5,30 @@ import type { OverflowContextValue } from './overflowContext'; import { OverflowContext } from './overflowContext'; describe('useOverflowVisibility', () => { - it('should return item and group visiblity', () => { + it('should return item and group visiblity derived from the snapshot', () => { const groupVisibility = { foo: 'hidden', bar: 'overflow', baz: 'visible', } as const; - const itemVisibility = { - foo: true, - bar: true, - baz: false, - } as const; - const Wrapper: React.FC = props => { + const snapshot = { + visibleItems: [ + { id: 'foo', element: document.createElement('div'), priority: 0 }, + { id: 'bar', element: document.createElement('div'), priority: 0 }, + ], + invisibleItems: [{ id: 'baz', element: document.createElement('div'), priority: 0 }], + groupVisibility, + }; + + const Wrapper: React.FC<{ children?: React.ReactNode }> = props => { return ( snapshot, + subscribe: () => () => null, } as unknown as OverflowContextValue } /> @@ -32,6 +36,6 @@ describe('useOverflowVisibility', () => { }; const { result } = renderHook(useOverflowVisibility, { wrapper: Wrapper }); expect(result.current.groupVisibility).toEqual(groupVisibility); - expect(result.current.itemVisibility).toEqual(itemVisibility); + expect(result.current.itemVisibility).toEqual({ foo: true, bar: true, baz: false }); }); }); diff --git a/packages/react-components/react-overflow/library/src/useOverflowVisibility.ts b/packages/react-components/react-overflow/library/src/useOverflowVisibility.ts index 6e406ff85ddb58..327fa10e6ff780 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowVisibility.ts +++ b/packages/react-components/react-overflow/library/src/useOverflowVisibility.ts @@ -1,7 +1,8 @@ 'use client'; +import type { OverflowEventPayload, OverflowGroupState } from '@fluentui/priority-overflow'; import * as React from 'react'; -import { useOverflowContext } from './overflowContext'; +import { useOverflowSnapshot } from './useOverflowSnapshot'; /** * A hook that returns the visibility status of all items and groups. @@ -14,10 +15,27 @@ import { useOverflowContext } from './overflowContext'; */ export function useOverflowVisibility(): { itemVisibility: Record; - groupVisibility: Record; + groupVisibility: Record; } { - const itemVisibility = useOverflowContext(ctx => ctx.itemVisibility); - const groupVisibility = useOverflowContext(ctx => ctx.groupVisibility); - - return React.useMemo(() => ({ itemVisibility, groupVisibility }), [itemVisibility, groupVisibility]); + const snapshot = useOverflowSnapshot(); + return React.useMemo(() => snapshotToVisibility(snapshot), [snapshot]); } + +const snapshotToVisibility = ( + snapshot: OverflowEventPayload, +): { + itemVisibility: Record; + groupVisibility: Record; +} => { + const itemVisibility: Record = {}; + snapshot.visibleItems.forEach(item => { + itemVisibility[item.id] = true; + }); + snapshot.invisibleItems.forEach(item => { + itemVisibility[item.id] = false; + }); + return { + itemVisibility, + groupVisibility: snapshot.groupVisibility, + }; +}; From dd41cbce24a068f834738c6bd4d46bf871a26e6d Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Thu, 28 May 2026 17:43:27 +0200 Subject: [PATCH 2/3] chore(react-overflow): unify noop callbacks via a shared noop constant - overflowContext.ts: replace the inline `() => () => null` / `() => null` default no-ops on the context value with references to a local `noop` const (block-form body). - useOverflowContainer.ts: `defaultSubscribe` now returns the same shared `noop` rather than re-allocating a fresh placeholder per call. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../react-overflow/library/src/overflowContext.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/react-components/react-overflow/library/src/overflowContext.ts b/packages/react-components/react-overflow/library/src/overflowContext.ts index d8b1ac7716518b..6bd5578b5af665 100644 --- a/packages/react-components/react-overflow/library/src/overflowContext.ts +++ b/packages/react-components/react-overflow/library/src/overflowContext.ts @@ -37,20 +37,24 @@ export const OverflowContext = React.createContext; +const noop = () => { + /* noop */ +}; + const overflowContextDefaultValue: OverflowContextValue = { hasOverflow: false, itemVisibility: {}, groupVisibility: {}, - registerItem: () => () => null, - updateOverflow: () => null, - registerOverflowMenu: () => () => null, - registerDivider: () => () => null, + registerItem: () => noop, + updateOverflow: noop, + registerOverflowMenu: () => noop, + registerDivider: () => noop, getSnapshot: () => ({ visibleItems: [], invisibleItems: [], groupVisibility: {}, }), - subscribe: () => () => null, + subscribe: () => noop, }; /** From de5968f482ff5ffe28be6525775fba4fc03c97ff Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Thu, 28 May 2026 17:54:29 +0200 Subject: [PATCH 3/3] chore: shorten PR2 change file comments Co-Authored-By: Claude Opus 4.7 (1M context) --- change/@fluentui-priority-overflow-pr2-subscribe.json | 2 +- change/@fluentui-react-overflow-pr2-subscribe.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/change/@fluentui-priority-overflow-pr2-subscribe.json b/change/@fluentui-priority-overflow-pr2-subscribe.json index b1ff950f2bb98d..a77b1fb535a592 100644 --- a/change/@fluentui-priority-overflow-pr2-subscribe.json +++ b/change/@fluentui-priority-overflow-pr2-subscribe.json @@ -1,6 +1,6 @@ { "type": "minor", - "comment": "feat: OverflowManager now exposes getSnapshot and subscribe so consumers can subscribe directly to overflow state without forcing intermediate re-renders.", + "comment": "feat: expose getSnapshot and subscribe on OverflowManager", "packageName": "@fluentui/priority-overflow", "email": "bsunderhus@microsoft.com", "dependentChangeType": "patch" diff --git a/change/@fluentui-react-overflow-pr2-subscribe.json b/change/@fluentui-react-overflow-pr2-subscribe.json index 22a4a3c193bbcb..78e7ba9fc7e01d 100644 --- a/change/@fluentui-react-overflow-pr2-subscribe.json +++ b/change/@fluentui-react-overflow-pr2-subscribe.json @@ -1,6 +1,6 @@ { "type": "patch", - "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.", + "comment": "fix: subscribe overflow hooks directly to the manager snapshot", "packageName": "@fluentui/react-overflow", "email": "bsunderhus@microsoft.com", "dependentChangeType": "patch"