diff --git a/change/@fluentui-priority-overflow-pr2-subscribe.json b/change/@fluentui-priority-overflow-pr2-subscribe.json new file mode 100644 index 00000000000000..a77b1fb535a592 --- /dev/null +++ b/change/@fluentui-priority-overflow-pr2-subscribe.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "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 new file mode 100644 index 00000000000000..78e7ba9fc7e01d --- /dev/null +++ b/change/@fluentui-react-overflow-pr2-subscribe.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "fix: subscribe overflow hooks directly to the manager snapshot", + "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..a00db825616617 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: OverflowSnapshot; + // @public export interface ObserveOptions { hasHiddenItems?: boolean; @@ -68,14 +71,23 @@ export interface OverflowManager { addOverflowMenu: (element: HTMLElement) => void; disconnect: () => void; forceUpdate: () => void; + getSnapshot: () => OverflowSnapshot; 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; } +// @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 cb1c1cad5c1c79..fb2b66cca386da 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 { OverflowSnapshot } 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: 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 ba4668ceab6d56..31db589584a72d 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, @@ -11,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 d937f4615f9ff1..4f3c28065a516c 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) => + Object.entries(manager.getSnapshot().itemVisibility) + .filter(([, visible]) => visible) + .map(([id]) => 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) => + Object.entries(manager.getSnapshot().itemVisibility) + .filter(([, visible]) => !visible) + .map(([id]) => 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({ + itemVisibility: {}, + groupVisibility: {}, + invisibleItemCount: 0, + }); }); 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({ + 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 c41f43617cb099..ceaa2d3bc78dd2 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'; @@ -9,6 +9,7 @@ import type { OverflowManager, ObserveOptions, OverflowDividerEntry, + OverflowSnapshot, } from './types'; const DEFAULT_OPTIONS: Required = { @@ -46,10 +47,17 @@ 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: OverflowSnapshot = EMPTY_SNAPSHOT; + const takeSnapshot = (nextSnapshot: OverflowSnapshot) => { + snapshot = nextSnapshot; + listeners.forEach(listener => listener()); + }; + const getNextItem = (queueToDequeue: PriorityQueue, queueToEnqueue: PriorityQueue) => { const nextItem = queueToDequeue.dequeue(); queueToEnqueue.enqueue(nextItem); @@ -108,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]) => @@ -153,15 +160,37 @@ export function createOverflowManager(initialOptions: Partial = }; const dispatchOverflowUpdate = () => { - const visibleItemIds = visibleItemQueue.all(); - const invisibleItemIds = invisibleItemQueue.all(); + const groupVisibility = groupManager.groupVisibility(); + + // 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]); + } - const visibleItems = visibleItemIds.map(itemId => overflowItems[itemId]); - const invisibleItems = invisibleItemIds.map(itemId => overflowItems[itemId]); + // Set the snapshot first so `getSnapshot()` is current for both subscribers and any + // `onUpdateOverflow` consumer that reads it. + takeSnapshot({ + itemVisibility, + groupVisibility, + invisibleItemCount: invisibleItems.length, + }); - options.onUpdateOverflow({ visibleItems, invisibleItems, groupVisibility: groupManager.groupVisibility() }); + // Legacy event payload: ordered item entries for `onUpdateOverflow` consumers. + options.onUpdateOverflow({ visibleItems, invisibleItems, groupVisibility }); }; + const getSnapshot: OverflowManager['getSnapshot'] = () => snapshot; + const processOverflowItems = (): boolean => { if (!container) { return false; @@ -271,6 +300,9 @@ 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(EMPTY_SNAPSHOT); }; const addItem: OverflowManager['addItem'] = items => { @@ -351,6 +383,14 @@ export function createOverflowManager(initialOptions: Partial = } }; + const subscribe: OverflowManager['subscribe'] = listener => { + listeners.add(listener); + + return () => { + listeners.delete(listener); + }; + }; + return { addItem, disconnect, @@ -363,6 +403,8 @@ export function createOverflowManager(initialOptions: Partial = addDivider, removeDivider, setOptions, + getSnapshot, + subscribe, }; } 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 51faa5a5ee4fd7..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. */ @@ -204,4 +227,14 @@ export interface OverflowManager { * Unsets the overflow menu element */ removeOverflowMenu: () => void; + + /** + * Returns the current overflow snapshot. + */ + getSnapshot: () => OverflowSnapshot; + + /** + * 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..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 @@ -4,12 +4,12 @@ ```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 { 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) @@ -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..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,7 +2,7 @@ import * as React from 'react'; import { mergeClasses } from '@griffel/react'; -import type { OnUpdateOverflow, OverflowGroupState, ObserveOptions } from '@fluentui/priority-overflow'; +import type { ObserveOptions, OnUpdateOverflow, OverflowGroupState } from '@fluentui/priority-overflow'; import { applyTriggerPropsToChildren, getTriggerChild, @@ -10,7 +10,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 +51,25 @@ 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, + const snapshot = getSnapshot(); + const state: OverflowState = { + hasOverflow: snapshot.invisibleItemCount > 0, + itemVisibility: snapshot.itemVisibility, + groupVisibility: snapshot.groupVisibility, }; - onOverflowChange?.(null, { ...newState }); - - setOverflowState(newState); + onOverflowChange?.(null, state); }; - 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 +77,21 @@ 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}; }); diff --git a/packages/react-components/react-overflow/library/src/overflowContext.ts b/packages/react-components/react-overflow/library/src/overflowContext.ts index 3cbe5d28c24430..274e8a2a931999 100644 --- a/packages/react-components/react-overflow/library/src/overflowContext.ts +++ b/packages/react-components/react-overflow/library/src/overflowContext.ts @@ -1,41 +1,72 @@ '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, + OverflowSnapshot, +} from '@fluentui/priority-overflow'; +import { EMPTY_SNAPSHOT } 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: () => OverflowSnapshot; + subscribe: (listener: () => void) => () => void; } -export const OverflowContext = createContext( +export const OverflowContext = React.createContext( undefined, -) as Context; +) as React.Context; + +const noop = () => { + /* noop */ +}; const overflowContextDefaultValue: OverflowContextValue = { + hasOverflow: false, itemVisibility: {}, groupVisibility: {}, - hasOverflow: false, - registerItem: () => () => null, - updateOverflow: () => null, - registerOverflowMenu: () => () => null, - registerDivider: () => () => null, + registerItem: () => noop, + updateOverflow: noop, + registerOverflowMenu: () => noop, + registerDivider: () => noop, + getSnapshot: () => EMPTY_SNAPSHOT, + subscribe: () => noop, }; /** * @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..1dad48304c8f4b 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(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 f23b26765de17f..0a390e6cb1fef6 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(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 d6badfedd4d649..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,6 +20,8 @@ const mockOverflowManager = (options: Partial = {}) => { addDivider: jest.fn(), removeDivider: jest.fn(), setOptions: jest.fn(), + getSnapshot: jest.fn(() => ({ itemVisibility: {}, groupVisibility: {}, invisibleItemCount: 0 })), + 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..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 @@ -118,12 +118,24 @@ export const useOverflowContainer = ( managerRef.current?.update(); }, []); + const getSnapshot = React.useCallback( + () => managerRef.current?.getSnapshot() ?? EMPTY_SNAPSHOT, + [], + ); + + const subscribe = React.useCallback( + listener => managerRef.current?.subscribe(listener) ?? noop, + [], + ); + return { registerItem, registerDivider, registerOverflowMenu, updateOverflow, containerRef, + getSnapshot, + subscribe, }; }; diff --git a/packages/react-components/react-overflow/library/src/useOverflowCount.ts b/packages/react-components/react-overflow/library/src/useOverflowCount.ts index 91fb75f14a7bc8..2f7c0ea2557033 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowCount.ts +++ b/packages/react-components/react-overflow/library/src/useOverflowCount.ts @@ -1,17 +1,11 @@ 'use client'; -import { useOverflowContext } from './overflowContext'; +import type { OverflowSnapshot } from '@fluentui/priority-overflow'; +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++; - } +export const useOverflowCount = (): number => useOverflowSnapshot(selectInvisibleItemCount); - return acc; - }, 0); - }); +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 new file mode 100644 index 00000000000000..73095120e2a006 --- /dev/null +++ b/packages/react-components/react-overflow/library/src/useOverflowSnapshot.ts @@ -0,0 +1,26 @@ +'use client'; + +import type { OverflowSnapshot } from '@fluentui/priority-overflow'; +import * as React from 'react'; +import { useEventCallback, useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; +import { useOverflowContext } from './overflowContext'; + +/** + * Subscribes to the overflow snapshot and returns a derived slice of it. + * + * @param selector - derives the slice of the snapshot the consumer depends on + */ +export function useOverflowSnapshot(selector: (snapshot: OverflowSnapshot) => Selected): Selected { + const { getSnapshot, subscribe } = useOverflowContext(); + const select = useEventCallback(selector); + + const [selected, setSelected] = React.useState(() => selector(getSnapshot())); + + useIsomorphicLayoutEffect(() => { + const checkForUpdates = () => setSelected(select(getSnapshot())); + checkForUpdates(); + return subscribe(checkForUpdates); + }, [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 508d0d1e8f3382..c0f190b477ed2c 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,27 @@ 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 = { + itemVisibility: { foo: true, bar: true, baz: false }, + groupVisibility, + invisibleItemCount: 1, + }; + + const Wrapper: React.FC<{ children?: React.ReactNode }> = props => { return ( snapshot, + subscribe: () => () => null, } as unknown as OverflowContextValue } /> @@ -32,6 +33,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..fef6a5f9b59d2b 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowVisibility.ts +++ b/packages/react-components/react-overflow/library/src/useOverflowVisibility.ts @@ -1,7 +1,7 @@ 'use client'; -import * as React from 'react'; -import { useOverflowContext } from './overflowContext'; +import type { OverflowGroupState, OverflowSnapshot } from '@fluentui/priority-overflow'; +import { useOverflowSnapshot } from './useOverflowSnapshot'; /** * A hook that returns the visibility status of all items and groups. @@ -14,10 +14,17 @@ 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]); + return useOverflowSnapshot(selectVisibility); } + +const selectVisibility = ( + snapshot: OverflowSnapshot, +): { + itemVisibility: Record; + groupVisibility: Record; +} => ({ + itemVisibility: snapshot.itemVisibility, + groupVisibility: snapshot.groupVisibility, +});