From e8fe764d06e6f2738057464296b90c566c8c2e05 Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Thu, 28 May 2026 12:33:22 +0200 Subject: [PATCH 1/5] 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/5] 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/5] 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" From 233a92f36dbdce9b33a0ec3fd926f5f6ab359ed5 Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Tue, 2 Jun 2026 09:35:36 +0200 Subject: [PATCH 4/5] perf(react-overflow): subscribe consumers to selected snapshot slices useOverflowSnapshot now takes a selector and only re-renders the consumer when the selected slice changes (per isEqual), instead of re-rendering every overflow hook on every snapshot change. Restores the selector-style subscription that was lost when the subscribe model dropped react-context-selector. Implemented with state + effects (the useSelector pattern) rather than useSyncExternalStore, to avoid forcing a synchronous re-render/flush on every snapshot change. selector and isEqual are wrapped in useEventCallback so the subscription effect stays stable and never re-subscribes. useOverflowCount, useIsOverflowItemVisible and useIsOverflowGroupVisible pass narrow selectors; useOverflowVisibility selects the full visibility maps (it still re-renders on every change, as documented). Also addresses review feedback: - Overflow's update callback uses optional chaining. - _overflowPayloadToState reuses the shared selectVisibility helper. - Adds a frozen EMPTY_SNAPSHOT constant in priority-overflow and uses it for every default/empty snapshot (manager, container, context). Co-Authored-By: Claude Opus 4.8 --- .../etc/priority-overflow.api.md | 3 ++ .../priority-overflow/src/consts.ts | 12 +++++ .../priority-overflow/src/index.ts | 1 + .../priority-overflow/src/overflowManager.ts | 14 ++---- .../library/src/components/Overflow.tsx | 22 +++------ .../library/src/overflowContext.ts | 7 +-- .../library/src/useIsOverflowGroupVisible.ts | 2 +- .../library/src/useIsOverflowItemVisible.ts | 2 +- .../library/src/useOverflowContainer.ts | 10 +--- .../library/src/useOverflowCount.ts | 5 +- .../library/src/useOverflowSnapshot.ts | 47 +++++++++++++++---- .../library/src/useOverflowVisibility.ts | 10 ++-- 12 files changed, 79 insertions(+), 56 deletions(-) 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 7186ac842abd47..d2fa1a7e2ce45c 100644 --- a/packages/react-components/priority-overflow/etc/priority-overflow.api.md +++ b/packages/react-components/priority-overflow/etc/priority-overflow.api.md @@ -7,6 +7,9 @@ // @internal export function createOverflowManager(initialOptions?: Partial): OverflowManager; +// @internal +export const EMPTY_SNAPSHOT: OverflowEventPayload; + // @public export interface ObserveOptions { hasHiddenItems?: boolean; diff --git a/packages/react-components/priority-overflow/src/consts.ts b/packages/react-components/priority-overflow/src/consts.ts index cb1c1cad5c1c79..74eafd4d6db456 100644 --- a/packages/react-components/priority-overflow/src/consts.ts +++ b/packages/react-components/priority-overflow/src/consts.ts @@ -1,2 +1,14 @@ +import type { OverflowEventPayload } from './types'; + export const DATA_OVERFLOWING = 'data-overflowing'; export const DATA_OVERFLOW_GROUP = 'data-overflow-group'; + +/** + * An empty, frozen overflow snapshot used as the default before anything has been measured. + * @internal + */ +export const EMPTY_SNAPSHOT: OverflowEventPayload = Object.freeze({ + visibleItems: [], + invisibleItems: [], + groupVisibility: {}, +}); diff --git a/packages/react-components/priority-overflow/src/index.ts b/packages/react-components/priority-overflow/src/index.ts index ba4668ceab6d56..ace24bbc702d3f 100644 --- a/packages/react-components/priority-overflow/src/index.ts +++ b/packages/react-components/priority-overflow/src/index.ts @@ -1,4 +1,5 @@ export { createOverflowManager } from './overflowManager'; +export { EMPTY_SNAPSHOT } from './consts'; export type { ObserveOptions, OnUpdateItemVisibility, diff --git a/packages/react-components/priority-overflow/src/overflowManager.ts b/packages/react-components/priority-overflow/src/overflowManager.ts index bf288a5548dc9b..70c4d7488ca3d4 100644 --- a/packages/react-components/priority-overflow/src/overflowManager.ts +++ b/packages/react-components/priority-overflow/src/overflowManager.ts @@ -1,4 +1,4 @@ -import { DATA_OVERFLOWING, DATA_OVERFLOW_GROUP } from './consts'; +import { DATA_OVERFLOWING, DATA_OVERFLOW_GROUP, EMPTY_SNAPSHOT } from './consts'; import { observeResize } from './createResizeObserver'; import { debounce } from './debounce'; import type { PriorityQueue } from './priorityQueue'; @@ -52,11 +52,7 @@ export function createOverflowManager(initialOptions: Partial = /* noop */ }; - let snapshot: OverflowEventPayload = { - visibleItems: [], - invisibleItems: [], - groupVisibility: {}, - }; + let snapshot: OverflowEventPayload = EMPTY_SNAPSHOT; const takeSnapshot = (nextSnapshot: OverflowEventPayload) => { snapshot = nextSnapshot; options.onUpdateOverflow(snapshot); @@ -292,11 +288,7 @@ export function createOverflowManager(initialOptions: Partial = sizeCache.clear(); // notify subscribers that the manager is no longer tracking anything - takeSnapshot({ - visibleItems: [], - invisibleItems: [], - groupVisibility: {}, - }); + takeSnapshot(EMPTY_SNAPSHOT); }; const addItem: OverflowManager['addItem'] = items => { 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 66a65c34e9546b..9c3c3157fa2b55 100644 --- a/packages/react-components/react-overflow/library/src/components/Overflow.tsx +++ b/packages/react-components/react-overflow/library/src/components/Overflow.tsx @@ -17,6 +17,7 @@ import { import { OverflowContext, type OverflowContextValue } from '../overflowContext'; import { updateVisibilityAttribute, useOverflowContainer } from '../useOverflowContainer'; +import { selectVisibility } from '../useOverflowVisibility'; import { useOverflowStyles } from './useOverflowStyles.styles'; interface OverflowState { @@ -57,10 +58,7 @@ export const Overflow = React.forwardRef((props: OverflowProps, ref) => { } = props; const update: OnUpdateOverflow = data => { - if (!onOverflowChange) { - return; - } - onOverflowChange(null, _overflowPayloadToState(data)); + onOverflowChange?.(null, _overflowPayloadToState(data)); }; const { containerRef, getSnapshot, subscribe, registerItem, updateOverflow, registerOverflowMenu, registerDivider } = @@ -101,15 +99,7 @@ export const Overflow = React.forwardRef((props: OverflowProps, ref) => { /** * @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, - }; -}; +export const _overflowPayloadToState = (data: OverflowEventPayload): OverflowState => ({ + ...selectVisibility(data), + 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 6bd5578b5af665..9d3789c399972f 100644 --- a/packages/react-components/react-overflow/library/src/overflowContext.ts +++ b/packages/react-components/react-overflow/library/src/overflowContext.ts @@ -6,6 +6,7 @@ import type { OverflowGroupState, OverflowEventPayload, } from '@fluentui/priority-overflow'; +import { EMPTY_SNAPSHOT } from '@fluentui/priority-overflow'; import * as React from 'react'; /** @@ -49,11 +50,7 @@ const overflowContextDefaultValue: OverflowContextValue = { updateOverflow: noop, registerOverflowMenu: () => noop, registerDivider: () => noop, - getSnapshot: () => ({ - visibleItems: [], - invisibleItems: [], - groupVisibility: {}, - }), + getSnapshot: () => EMPTY_SNAPSHOT, subscribe: () => noop, }; diff --git a/packages/react-components/react-overflow/library/src/useIsOverflowGroupVisible.ts b/packages/react-components/react-overflow/library/src/useIsOverflowGroupVisible.ts index 145bef2063fed2..1dad48304c8f4b 100644 --- a/packages/react-components/react-overflow/library/src/useIsOverflowGroupVisible.ts +++ b/packages/react-components/react-overflow/library/src/useIsOverflowGroupVisible.ts @@ -8,5 +8,5 @@ import { useOverflowSnapshot } from './useOverflowSnapshot'; * @returns visibility state of the group */ export function useIsOverflowGroupVisible(id: string): OverflowGroupState { - return useOverflowSnapshot().groupVisibility[id]; + return useOverflowSnapshot(snapshot => snapshot.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 f7e066af9fe976..c73161502386bb 100644 --- a/packages/react-components/react-overflow/library/src/useIsOverflowItemVisible.ts +++ b/packages/react-components/react-overflow/library/src/useIsOverflowItemVisible.ts @@ -7,5 +7,5 @@ import { useOverflowSnapshot } from './useOverflowSnapshot'; * @returns visibility state of an overflow item */ export function useIsOverflowItemVisible(id: string): boolean { - return useOverflowSnapshot().visibleItems.some(item => item.id === id); + return useOverflowSnapshot(snapshot => snapshot.visibleItems.some(item => item.id === id)); } diff --git a/packages/react-components/react-overflow/library/src/useOverflowContainer.ts b/packages/react-components/react-overflow/library/src/useOverflowContainer.ts index 6f4146a5dcf5c5..7796a392f3e1e4 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowContainer.ts +++ b/packages/react-components/react-overflow/library/src/useOverflowContainer.ts @@ -1,7 +1,7 @@ 'use client'; import * as React from 'react'; -import { createOverflowManager } from '@fluentui/priority-overflow'; +import { createOverflowManager, EMPTY_SNAPSHOT } from '@fluentui/priority-overflow'; /** * @internal @@ -119,7 +119,7 @@ export const useOverflowContainer = ( }, []); const getSnapshot = React.useCallback( - () => managerRef.current?.getSnapshot() ?? defaultSnapshot, + () => managerRef.current?.getSnapshot() ?? EMPTY_SNAPSHOT, [], ); @@ -143,12 +143,6 @@ 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 d57225462baf92..191d415caf6cfd 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowCount.ts +++ b/packages/react-components/react-overflow/library/src/useOverflowCount.ts @@ -1,8 +1,11 @@ 'use client'; +import type { OverflowEventPayload } from '@fluentui/priority-overflow'; import { useOverflowSnapshot } from './useOverflowSnapshot'; /** * @returns Number of items that are overflowing */ -export const useOverflowCount = (): number => useOverflowSnapshot().invisibleItems.length; +export const useOverflowCount = (): number => useOverflowSnapshot(selectInvisibleItemCount); + +const selectInvisibleItemCount = (snapshot: OverflowEventPayload): number => snapshot.invisibleItems.length; diff --git a/packages/react-components/react-overflow/library/src/useOverflowSnapshot.ts b/packages/react-components/react-overflow/library/src/useOverflowSnapshot.ts index 7747c53f69ad43..97984ab0a6df24 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowSnapshot.ts +++ b/packages/react-components/react-overflow/library/src/useOverflowSnapshot.ts @@ -2,16 +2,45 @@ import type { OverflowEventPayload } from '@fluentui/priority-overflow'; import * as React from 'react'; +import { useEventCallback, useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; import { useOverflowContext } from './overflowContext'; -import { useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; -export const useOverflowSnapshot = (): OverflowEventPayload => { +/** + * Subscribes to the overflow snapshot and returns a derived slice of it. + * + * Only re-renders the consuming component when the selected slice changes (per `isEqual`), so hooks + * like `useIsOverflowItemVisible` don't re-render on every unrelated overflow change. Intentionally + * implemented with state + effects (the `useSelector` pattern) rather than `useSyncExternalStore`, + * which would force a synchronous re-render/flush on every snapshot change. + * + * @param selector - derives the slice of the snapshot the consumer depends on + * @param isEqual - compares the previous and next slice; defaults to `Object.is` + */ +export function useOverflowSnapshot( + selector: (snapshot: OverflowEventPayload) => Selected, + isEqual: (a: Selected, b: Selected) => boolean = Object.is, +): Selected { const { getSnapshot, subscribe } = useOverflowContext(); - const [snapshot, setSnapshot] = React.useState(() => getSnapshot()); + + // Stable wrappers around the latest selector/isEqual, so the subscription effect runs once and + // does not need to re-subscribe when an inline selector changes identity between renders. + const select = useEventCallback(selector); + const compareSelected = useEventCallback(isEqual); + + const [selected, setSelected] = React.useState(() => selector(getSnapshot())); + useIsomorphicLayoutEffect(() => { - return subscribe(() => { - setSnapshot(getSnapshot()); - }); - }, [subscribe, getSnapshot]); - return snapshot; -}; + const checkForUpdates = () => { + setSelected(previous => { + const next = select(getSnapshot()); + return compareSelected(previous, next) ? previous : next; + }); + }; + + // The snapshot may have changed between render and subscription. + checkForUpdates(); + return subscribe(checkForUpdates); + }, [subscribe, getSnapshot, select, compareSelected]); + + return selected; +} diff --git a/packages/react-components/react-overflow/library/src/useOverflowVisibility.ts b/packages/react-components/react-overflow/library/src/useOverflowVisibility.ts index 327fa10e6ff780..2b516a7bbe873d 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowVisibility.ts +++ b/packages/react-components/react-overflow/library/src/useOverflowVisibility.ts @@ -1,7 +1,6 @@ 'use client'; import type { OverflowEventPayload, OverflowGroupState } from '@fluentui/priority-overflow'; -import * as React from 'react'; import { useOverflowSnapshot } from './useOverflowSnapshot'; /** @@ -17,11 +16,14 @@ export function useOverflowVisibility(): { itemVisibility: Record; groupVisibility: Record; } { - const snapshot = useOverflowSnapshot(); - return React.useMemo(() => snapshotToVisibility(snapshot), [snapshot]); + return useOverflowSnapshot(selectVisibility); } -const snapshotToVisibility = ( +/** + * Derives the item and group visibility maps from an overflow snapshot. + * @internal + */ +export const selectVisibility = ( snapshot: OverflowEventPayload, ): { itemVisibility: Record; From 72707268f8bebbffd3ea522fec2226b259a8fd6f Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Tue, 2 Jun 2026 10:24:04 +0200 Subject: [PATCH 5/5] refactor(priority-overflow): give getSnapshot a purpose-built OverflowSnapshot getSnapshot/subscribe are new (unreleased) API, so they need not return the legacy OverflowEventPayload. Introduce OverflowSnapshot, shaped for O(1) consumer lookups, while onUpdateOverflow keeps the ordered-array OverflowEventPayload for direct/external manager users. OverflowSnapshot: - itemVisibility: Record // O(1), replaces visibleItems.some() - groupVisibility: Record - invisibleItemCount: number The manager builds both representations once per dispatch and stores a fresh, immutable snapshot (groupVisibility is copied rather than the mutated-in-place reference). disconnect now notifies subscribers only, without firing the legacy onUpdateOverflow event. react-overflow consumers select narrow slices: useIsOverflowItemVisible -> itemVisibility[id], useOverflowCount -> invisibleItemCount, useIsOverflowGroupVisible -> groupVisibility[id], useOverflowVisibility -> the two maps. This removes the per-item O(n) key polling in the hot path. Co-Authored-By: Claude Opus 4.8 --- .../etc/priority-overflow.api.md | 11 ++++- .../priority-overflow/src/consts.ts | 8 ++-- .../priority-overflow/src/index.ts | 1 + .../src/overflowManager.test.ts | 20 ++++---- .../priority-overflow/src/overflowManager.ts | 46 ++++++++++++------- .../priority-overflow/src/priorityQueue.ts | 16 ++++--- .../priority-overflow/src/types.ts | 27 ++++++++++- .../library/etc/react-overflow.api.md | 2 +- .../library/src/components/Overflow.tsx | 24 ++++------ .../library/src/overflowContext.ts | 4 +- .../library/src/useIsOverflowItemVisible.ts | 2 +- .../library/src/useOverflowContainer.test.tsx | 2 +- .../library/src/useOverflowCount.ts | 4 +- .../library/src/useOverflowSnapshot.ts | 28 ++--------- .../src/useOverflowVisibility.test.tsx | 7 +-- .../library/src/useOverflowVisibility.ts | 27 +++-------- 16 files changed, 116 insertions(+), 113 deletions(-) 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 d2fa1a7e2ce45c..a00db825616617 100644 --- a/packages/react-components/priority-overflow/etc/priority-overflow.api.md +++ b/packages/react-components/priority-overflow/etc/priority-overflow.api.md @@ -8,7 +8,7 @@ export function createOverflowManager(initialOptions?: Partial): OverflowManager; // @internal -export const EMPTY_SNAPSHOT: OverflowEventPayload; +export const EMPTY_SNAPSHOT: OverflowSnapshot; // @public export interface ObserveOptions { @@ -71,7 +71,7 @@ export interface OverflowManager { addOverflowMenu: (element: HTMLElement) => void; disconnect: () => void; forceUpdate: () => void; - getSnapshot: () => OverflowEventPayload; + getSnapshot: () => OverflowSnapshot; observe: (container: HTMLElement, options?: ObserveOptions) => void; removeDivider: (groupId: string) => void; removeItem: (itemId: string) => void; @@ -81,6 +81,13 @@ export interface OverflowManager { update: () => void; } +// @public +export interface OverflowSnapshot { + groupVisibility: Record; + invisibleItemCount: number; + itemVisibility: Record; +} + // (No @packageDocumentation comment for this package) ``` diff --git a/packages/react-components/priority-overflow/src/consts.ts b/packages/react-components/priority-overflow/src/consts.ts index 74eafd4d6db456..fb2b66cca386da 100644 --- a/packages/react-components/priority-overflow/src/consts.ts +++ b/packages/react-components/priority-overflow/src/consts.ts @@ -1,4 +1,4 @@ -import type { OverflowEventPayload } from './types'; +import type { OverflowSnapshot } from './types'; export const DATA_OVERFLOWING = 'data-overflowing'; export const DATA_OVERFLOW_GROUP = 'data-overflow-group'; @@ -7,8 +7,8 @@ export const DATA_OVERFLOW_GROUP = 'data-overflow-group'; * An empty, frozen overflow snapshot used as the default before anything has been measured. * @internal */ -export const EMPTY_SNAPSHOT: OverflowEventPayload = Object.freeze({ - visibleItems: [], - invisibleItems: [], +export const EMPTY_SNAPSHOT: OverflowSnapshot = Object.freeze({ + itemVisibility: {}, groupVisibility: {}, + invisibleItemCount: 0, }); diff --git a/packages/react-components/priority-overflow/src/index.ts b/packages/react-components/priority-overflow/src/index.ts index ace24bbc702d3f..31db589584a72d 100644 --- a/packages/react-components/priority-overflow/src/index.ts +++ b/packages/react-components/priority-overflow/src/index.ts @@ -12,4 +12,5 @@ export type { OverflowItemEntry, OverflowDividerEntry, OverflowManager, + OverflowSnapshot, } from './types'; diff --git a/packages/react-components/priority-overflow/src/overflowManager.test.ts b/packages/react-components/priority-overflow/src/overflowManager.test.ts index 8e33bde836bf98..4f3c28065a516c 100644 --- a/packages/react-components/priority-overflow/src/overflowManager.test.ts +++ b/packages/react-components/priority-overflow/src/overflowManager.test.ts @@ -46,15 +46,15 @@ describe('overflowManager', () => { }); const getVisibleIds = (manager: ReturnType) => - manager - .getSnapshot() - .visibleItems.map(item => item.id) + Object.entries(manager.getSnapshot().itemVisibility) + .filter(([, visible]) => visible) + .map(([id]) => id) .sort(); const getInvisibleIds = (manager: ReturnType) => - manager - .getSnapshot() - .invisibleItems.map(item => item.id) + Object.entries(manager.getSnapshot().itemVisibility) + .filter(([, visible]) => !visible) + .map(([id]) => id) .sort(); it('should expose a stable snapshot after forceUpdate', () => { @@ -130,9 +130,9 @@ describe('overflowManager', () => { manager.disconnect(); expect(manager.getSnapshot()).toEqual({ - visibleItems: [], - invisibleItems: [], + itemVisibility: {}, groupVisibility: {}, + invisibleItemCount: 0, }); }); @@ -151,9 +151,9 @@ describe('overflowManager', () => { manager.forceUpdate(); expect(manager.getSnapshot()).toEqual({ - visibleItems: [], - invisibleItems: [], + itemVisibility: {}, groupVisibility: {}, + invisibleItemCount: 0, }); }); }); diff --git a/packages/react-components/priority-overflow/src/overflowManager.ts b/packages/react-components/priority-overflow/src/overflowManager.ts index 70c4d7488ca3d4..ceaa2d3bc78dd2 100644 --- a/packages/react-components/priority-overflow/src/overflowManager.ts +++ b/packages/react-components/priority-overflow/src/overflowManager.ts @@ -9,7 +9,7 @@ import type { OverflowManager, ObserveOptions, OverflowDividerEntry, - OverflowEventPayload, + OverflowSnapshot, } from './types'; const DEFAULT_OPTIONS: Required = { @@ -52,10 +52,9 @@ export function createOverflowManager(initialOptions: Partial = /* noop */ }; - let snapshot: OverflowEventPayload = EMPTY_SNAPSHOT; - const takeSnapshot = (nextSnapshot: OverflowEventPayload) => { + let snapshot: OverflowSnapshot = EMPTY_SNAPSHOT; + const takeSnapshot = (nextSnapshot: OverflowSnapshot) => { snapshot = nextSnapshot; - options.onUpdateOverflow(snapshot); listeners.forEach(listener => listener()); }; @@ -117,11 +116,10 @@ export function createOverflowManager(initialOptions: Partial = const visibleItemQueue = createPriorityQueue(compareItems); function occupiedSize(): number { - const totalItemSize = visibleItemQueue - .all() - .map(id => overflowItems[id].element) - .map(getOffsetSize) - .reduce((prev, current) => prev + current, 0); + let totalItemSize = 0; + for (const id of visibleItemQueue) { + totalItemSize += getOffsetSize(overflowItems[id].element); + } const totalDividerSize = Object.entries(groupManager.groupVisibility()).reduce( (acc, [id, state]) => @@ -162,17 +160,33 @@ export function createOverflowManager(initialOptions: Partial = }; const dispatchOverflowUpdate = () => { - const visibleItemIds = visibleItemQueue.all(); - const invisibleItemIds = invisibleItemQueue.all(); + const groupVisibility = groupManager.groupVisibility(); - const visibleItems = visibleItemIds.map(itemId => overflowItems[itemId]); - const invisibleItems = invisibleItemIds.map(itemId => overflowItems[itemId]); + // Build the legacy ordered-entry arrays and the snapshot's id -> visible map in a single pass + // over each queue. + const itemVisibility: Record = {}; + const visibleItems: OverflowItemEntry[] = []; + const invisibleItems: OverflowItemEntry[] = []; + for (const itemId of visibleItemQueue) { + itemVisibility[itemId] = true; + visibleItems.push(overflowItems[itemId]); + } + for (const itemId of invisibleItemQueue) { + itemVisibility[itemId] = false; + invisibleItems.push(overflowItems[itemId]); + } + + // Set the snapshot first so `getSnapshot()` is current for both subscribers and any + // `onUpdateOverflow` consumer that reads it. takeSnapshot({ - visibleItems, - invisibleItems, - groupVisibility: groupManager.groupVisibility(), + itemVisibility, + groupVisibility, + invisibleItemCount: invisibleItems.length, }); + + // Legacy event payload: ordered item entries for `onUpdateOverflow` consumers. + options.onUpdateOverflow({ visibleItems, invisibleItems, groupVisibility }); }; const getSnapshot: OverflowManager['getSnapshot'] = () => snapshot; diff --git a/packages/react-components/priority-overflow/src/priorityQueue.ts b/packages/react-components/priority-overflow/src/priorityQueue.ts index 719559586eb00f..ea25aba6a4a1dd 100644 --- a/packages/react-components/priority-overflow/src/priorityQueue.ts +++ b/packages/react-components/priority-overflow/src/priorityQueue.ts @@ -1,7 +1,6 @@ export type PriorityQueueCompareFn = (a: T, b: T) => number; -export interface PriorityQueue { - all: () => T[]; +export interface PriorityQueue extends Iterable { clear: () => void; contains: (item: T) => boolean; dequeue: () => T; @@ -107,12 +106,7 @@ export function createPriorityQueue(compare: PriorityQueueCompareFn): Prio size = 0; }; - const all = () => { - return arr.slice(0, size); - }; - return { - all, clear, contains, dequeue, @@ -120,5 +114,13 @@ export function createPriorityQueue(compare: PriorityQueueCompareFn): Prio peek, remove, size: () => size, + // Iterates items in heap order, without allocating an intermediate array. Bounded by `size`: + // `arr` keeps stale entries past `size` (dequeue/remove/clear shrink `size`, not `arr`), so the + // array's own iterator would yield removed items. + *[Symbol.iterator]() { + for (let index = 0; index < size; index++) { + yield arr[index]; + } + }, }; } diff --git a/packages/react-components/priority-overflow/src/types.ts b/packages/react-components/priority-overflow/src/types.ts index bf4bda0d5b08cf..961392fcf0c352 100644 --- a/packages/react-components/priority-overflow/src/types.ts +++ b/packages/react-components/priority-overflow/src/types.ts @@ -89,6 +89,29 @@ export interface OverflowEventPayload { groupVisibility: Record; } +/** + * Indexed, immutable overflow state returned by `OverflowManager.getSnapshot`. + * + * Unlike {@link OverflowEventPayload}, this is shaped for O(1) consumer lookups (item/group + * visibility by id, item count) rather than ordered item entries. + */ +export interface OverflowSnapshot { + /** + * Visibility of each registered item, keyed by item id. + */ + itemVisibility: Record; + + /** + * Current visibility state by group id. + */ + groupVisibility: Record; + + /** + * Number of items currently moved to overflow (invisible). + */ + invisibleItemCount: number; +} + /** * Payload for item-level visibility updates. */ @@ -206,9 +229,9 @@ export interface OverflowManager { removeOverflowMenu: () => void; /** - * Returns the current canonical overflow snapshot. + * Returns the current overflow snapshot. */ - getSnapshot: () => OverflowEventPayload; + getSnapshot: () => OverflowSnapshot; /** * Subscribes to snapshot changes. 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 f9a9c4759d7c83..41c5be9ffd4a9e 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 @@ -7,9 +7,9 @@ import type { ObserveOptions } from '@fluentui/priority-overflow'; import type { OnUpdateOverflow } from '@fluentui/priority-overflow'; import type { OverflowDividerEntry } 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 type { OverflowSnapshot } from '@fluentui/priority-overflow'; import * as React_2 from 'react'; // @public (undocumented) 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 9c3c3157fa2b55..9266ef6a554191 100644 --- a/packages/react-components/react-overflow/library/src/components/Overflow.tsx +++ b/packages/react-components/react-overflow/library/src/components/Overflow.tsx @@ -2,12 +2,7 @@ import * as React from 'react'; import { mergeClasses } from '@griffel/react'; -import type { - ObserveOptions, - OnUpdateOverflow, - OverflowEventPayload, - OverflowGroupState, -} from '@fluentui/priority-overflow'; +import type { ObserveOptions, OnUpdateOverflow, OverflowGroupState } from '@fluentui/priority-overflow'; import { applyTriggerPropsToChildren, getTriggerChild, @@ -17,7 +12,6 @@ import { import { OverflowContext, type OverflowContextValue } from '../overflowContext'; import { updateVisibilityAttribute, useOverflowContainer } from '../useOverflowContainer'; -import { selectVisibility } from '../useOverflowVisibility'; import { useOverflowStyles } from './useOverflowStyles.styles'; interface OverflowState { @@ -58,7 +52,13 @@ export const Overflow = React.forwardRef((props: OverflowProps, ref) => { } = props; const update: OnUpdateOverflow = data => { - onOverflowChange?.(null, _overflowPayloadToState(data)); + const snapshot = getSnapshot(); + const state: OverflowState = { + hasOverflow: snapshot.invisibleItemCount > 0, + itemVisibility: snapshot.itemVisibility, + groupVisibility: snapshot.groupVisibility, + }; + onOverflowChange?.(null, state); }; const { containerRef, getSnapshot, subscribe, registerItem, updateOverflow, registerOverflowMenu, registerDivider } = @@ -95,11 +95,3 @@ export const Overflow = React.forwardRef((props: OverflowProps, ref) => { return {clonedChild}; }); - -/** - * @internal - */ -export const _overflowPayloadToState = (data: OverflowEventPayload): OverflowState => ({ - ...selectVisibility(data), - 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 9d3789c399972f..274e8a2a931999 100644 --- a/packages/react-components/react-overflow/library/src/overflowContext.ts +++ b/packages/react-components/react-overflow/library/src/overflowContext.ts @@ -4,7 +4,7 @@ import type { OverflowItemEntry, OverflowDividerEntry, OverflowGroupState, - OverflowEventPayload, + OverflowSnapshot, } from '@fluentui/priority-overflow'; import { EMPTY_SNAPSHOT } from '@fluentui/priority-overflow'; import * as React from 'react'; @@ -30,7 +30,7 @@ export interface OverflowContextValue { registerDivider: (divider: OverflowDividerEntry) => () => void; updateOverflow: (padding?: number) => void; containerRef?: React.RefObject; - getSnapshot: () => OverflowEventPayload; + getSnapshot: () => OverflowSnapshot; subscribe: (listener: () => void) => () => void; } diff --git a/packages/react-components/react-overflow/library/src/useIsOverflowItemVisible.ts b/packages/react-components/react-overflow/library/src/useIsOverflowItemVisible.ts index c73161502386bb..0a390e6cb1fef6 100644 --- a/packages/react-components/react-overflow/library/src/useIsOverflowItemVisible.ts +++ b/packages/react-components/react-overflow/library/src/useIsOverflowItemVisible.ts @@ -7,5 +7,5 @@ import { useOverflowSnapshot } from './useOverflowSnapshot'; * @returns visibility state of an overflow item */ export function useIsOverflowItemVisible(id: string): boolean { - return useOverflowSnapshot(snapshot => snapshot.visibleItems.some(item => item.id === id)); + return useOverflowSnapshot(snapshot => !!snapshot.itemVisibility[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 a48bef71294df8..9e24facd3a82ef 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowContainer.test.tsx +++ b/packages/react-components/react-overflow/library/src/useOverflowContainer.test.tsx @@ -20,7 +20,7 @@ const mockOverflowManager = (options: Partial = {}) => { addDivider: jest.fn(), removeDivider: jest.fn(), setOptions: jest.fn(), - getSnapshot: jest.fn(() => ({ visibleItems: [], invisibleItems: [], groupVisibility: {} })), + getSnapshot: jest.fn(() => ({ itemVisibility: {}, groupVisibility: {}, invisibleItemCount: 0 })), subscribe: jest.fn(() => () => null), }; (createOverflowManager as jest.Mock).mockReturnValue({ diff --git a/packages/react-components/react-overflow/library/src/useOverflowCount.ts b/packages/react-components/react-overflow/library/src/useOverflowCount.ts index 191d415caf6cfd..2f7c0ea2557033 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowCount.ts +++ b/packages/react-components/react-overflow/library/src/useOverflowCount.ts @@ -1,6 +1,6 @@ 'use client'; -import type { OverflowEventPayload } from '@fluentui/priority-overflow'; +import type { OverflowSnapshot } from '@fluentui/priority-overflow'; import { useOverflowSnapshot } from './useOverflowSnapshot'; /** @@ -8,4 +8,4 @@ import { useOverflowSnapshot } from './useOverflowSnapshot'; */ export const useOverflowCount = (): number => useOverflowSnapshot(selectInvisibleItemCount); -const selectInvisibleItemCount = (snapshot: OverflowEventPayload): number => snapshot.invisibleItems.length; +const selectInvisibleItemCount = (snapshot: OverflowSnapshot): number => snapshot.invisibleItemCount; diff --git a/packages/react-components/react-overflow/library/src/useOverflowSnapshot.ts b/packages/react-components/react-overflow/library/src/useOverflowSnapshot.ts index 97984ab0a6df24..73095120e2a006 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowSnapshot.ts +++ b/packages/react-components/react-overflow/library/src/useOverflowSnapshot.ts @@ -1,6 +1,6 @@ 'use client'; -import type { OverflowEventPayload } from '@fluentui/priority-overflow'; +import type { OverflowSnapshot } from '@fluentui/priority-overflow'; import * as React from 'react'; import { useEventCallback, useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; import { useOverflowContext } from './overflowContext'; @@ -8,39 +8,19 @@ import { useOverflowContext } from './overflowContext'; /** * Subscribes to the overflow snapshot and returns a derived slice of it. * - * Only re-renders the consuming component when the selected slice changes (per `isEqual`), so hooks - * like `useIsOverflowItemVisible` don't re-render on every unrelated overflow change. Intentionally - * implemented with state + effects (the `useSelector` pattern) rather than `useSyncExternalStore`, - * which would force a synchronous re-render/flush on every snapshot change. - * * @param selector - derives the slice of the snapshot the consumer depends on - * @param isEqual - compares the previous and next slice; defaults to `Object.is` */ -export function useOverflowSnapshot( - selector: (snapshot: OverflowEventPayload) => Selected, - isEqual: (a: Selected, b: Selected) => boolean = Object.is, -): Selected { +export function useOverflowSnapshot(selector: (snapshot: OverflowSnapshot) => Selected): Selected { const { getSnapshot, subscribe } = useOverflowContext(); - - // Stable wrappers around the latest selector/isEqual, so the subscription effect runs once and - // does not need to re-subscribe when an inline selector changes identity between renders. const select = useEventCallback(selector); - const compareSelected = useEventCallback(isEqual); const [selected, setSelected] = React.useState(() => selector(getSnapshot())); useIsomorphicLayoutEffect(() => { - const checkForUpdates = () => { - setSelected(previous => { - const next = select(getSnapshot()); - return compareSelected(previous, next) ? previous : next; - }); - }; - - // The snapshot may have changed between render and subscription. + const checkForUpdates = () => setSelected(select(getSnapshot())); checkForUpdates(); return subscribe(checkForUpdates); - }, [subscribe, getSnapshot, select, compareSelected]); + }, [subscribe, getSnapshot, select]); return selected; } 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 55a256891b41e7..c0f190b477ed2c 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowVisibility.test.tsx +++ b/packages/react-components/react-overflow/library/src/useOverflowVisibility.test.tsx @@ -13,12 +13,9 @@ describe('useOverflowVisibility', () => { } as const; 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 }], + itemVisibility: { foo: true, bar: true, baz: false }, groupVisibility, + invisibleItemCount: 1, }; const Wrapper: React.FC<{ children?: React.ReactNode }> = props => { diff --git a/packages/react-components/react-overflow/library/src/useOverflowVisibility.ts b/packages/react-components/react-overflow/library/src/useOverflowVisibility.ts index 2b516a7bbe873d..fef6a5f9b59d2b 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowVisibility.ts +++ b/packages/react-components/react-overflow/library/src/useOverflowVisibility.ts @@ -1,6 +1,6 @@ 'use client'; -import type { OverflowEventPayload, OverflowGroupState } from '@fluentui/priority-overflow'; +import type { OverflowGroupState, OverflowSnapshot } from '@fluentui/priority-overflow'; import { useOverflowSnapshot } from './useOverflowSnapshot'; /** @@ -19,25 +19,12 @@ export function useOverflowVisibility(): { return useOverflowSnapshot(selectVisibility); } -/** - * Derives the item and group visibility maps from an overflow snapshot. - * @internal - */ -export const selectVisibility = ( - snapshot: OverflowEventPayload, +const selectVisibility = ( + snapshot: OverflowSnapshot, ): { 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, - }; -}; +} => ({ + itemVisibility: snapshot.itemVisibility, + groupVisibility: snapshot.groupVisibility, +});