diff --git a/change/@fluentui-priority-overflow-pr1-strict-mode.json b/change/@fluentui-priority-overflow-pr1-strict-mode.json new file mode 100644 index 00000000000000..3bc5e5da771068 --- /dev/null +++ b/change/@fluentui-priority-overflow-pr1-strict-mode.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: createOverflowManager accepts initialOptions, new setOptions method, observe now returns its cleanup, and the OverflowManagerOptions type is exported.", + "packageName": "@fluentui/priority-overflow", + "email": "bsunderhus@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-overflow-pr1-strict-mode.json b/change/@fluentui-react-overflow-pr1-strict-mode.json new file mode 100644 index 00000000000000..d699834de9cd53 --- /dev/null +++ b/change/@fluentui-react-overflow-pr1-strict-mode.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "fix: make Overflow container strict-mode safe", + "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 bf10a02394bb8a..2a720d068daf9e 100644 --- a/packages/react-components/priority-overflow/etc/priority-overflow.api.md +++ b/packages/react-components/priority-overflow/etc/priority-overflow.api.md @@ -4,10 +4,10 @@ ```ts -// @internal (undocumented) -export function createOverflowManager(): OverflowManager; +// @internal +export function createOverflowManager(initialOptions?: Partial): OverflowManager; -// @public (undocumented) +// @public export interface ObserveOptions { hasHiddenItems?: boolean; minimumVisible?: number; @@ -18,67 +18,61 @@ export interface ObserveOptions { padding?: number; } -// @public (undocumented) +// @public export type OnUpdateItemVisibility = (data: OnUpdateItemVisibilityPayload) => void; -// @public (undocumented) +// @public export interface OnUpdateItemVisibilityPayload { - // (undocumented) item: OverflowItemEntry; - // (undocumented) visible: boolean; } // @public export type OnUpdateOverflow = (data: OverflowEventPayload) => void; -// @public (undocumented) +// @public export type OverflowAxis = 'horizontal' | 'vertical'; -// @public (undocumented) +// @public export type OverflowDirection = 'start' | 'end'; -// @public (undocumented) +// @public export interface OverflowDividerEntry { element: HTMLElement; - // (undocumented) groupId: string; } // @public export interface OverflowEventPayload { - // (undocumented) groupVisibility: Record; - // (undocumented) invisibleItems: OverflowItemEntry[]; - // (undocumented) visibleItems: OverflowItemEntry[]; } -// @public (undocumented) +// @public export type OverflowGroupState = 'visible' | 'hidden' | 'overflow'; -// @public (undocumented) +// @public export interface OverflowItemEntry { element: HTMLElement; - // (undocumented) groupId?: string; id: string; pinned?: boolean; priority: number; } -// @internal (undocumented) +// @internal export interface OverflowManager { addDivider: (divider: OverflowDividerEntry) => void; addItem: (items: OverflowItemEntry) => void; addOverflowMenu: (element: HTMLElement) => void; disconnect: () => void; forceUpdate: () => void; - observe: (container: HTMLElement, options: ObserveOptions) => void; + observe: (container: HTMLElement, options?: ObserveOptions) => void; removeDivider: (groupId: string) => void; removeItem: (itemId: string) => void; removeOverflowMenu: () => void; + setOptions: (options: Partial) => 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 new file mode 100644 index 00000000000000..d937f4615f9ff1 --- /dev/null +++ b/packages/react-components/priority-overflow/src/overflowManager.test.ts @@ -0,0 +1,155 @@ +import { createOverflowManager } from './overflowManager'; +import type { ObserveOptions, OverflowEventPayload } from './types'; + +describe('overflowManager', () => { + beforeAll(() => { + global.ResizeObserver = class ResizeObserver { + public observe() { + // do nothing + } + + public unobserve() { + // do nothing + } + + public disconnect() { + // do nothing + } + } as unknown as typeof ResizeObserver; + }); + + const createElementWithSize = (tagName: string, width: number) => { + const element = document.createElement(tagName); + Object.defineProperty(element, 'offsetWidth', { configurable: true, value: width }); + Object.defineProperty(element, 'offsetHeight', { configurable: true, value: width }); + + return element; + }; + + const createContainer = (width: number) => { + const container = document.createElement('div'); + Object.defineProperty(container, 'clientWidth', { configurable: true, value: width }); + Object.defineProperty(container, 'clientHeight', { configurable: true, value: width }); + + return container; + }; + + const createObserveOptions = (options: Partial = {}): ObserveOptions => ({ + overflowAxis: 'horizontal', + overflowDirection: 'end', + padding: 10, + minimumVisible: 0, + hasHiddenItems: false, + onUpdateItemVisibility: jest.fn(), + onUpdateOverflow: jest.fn(), + ...options, + }); + + const lastDispatch = (onUpdateOverflow: jest.Mock): OverflowEventPayload => + onUpdateOverflow.mock.calls[onUpdateOverflow.mock.calls.length - 1][0]; + + it('should dispatch overflow update after forceUpdate', () => { + const onUpdateOverflow = jest.fn(); + const options = createObserveOptions({ onUpdateOverflow }); + const manager = createOverflowManager(options); + const container = createContainer(100); + const itemA = createElementWithSize('button', 40); + const itemB = createElementWithSize('button', 40); + const menu = createElementWithSize('button', 20); + + 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 dispatch = lastDispatch(onUpdateOverflow); + expect(dispatch.visibleItems.map(item => item.id).sort()).toEqual(['a', 'b']); + expect(dispatch.invisibleItems).toEqual([]); + expect(dispatch.groupVisibility).toEqual({}); + }); + + it('should re-dispatch when setOptions changes a relevant option', () => { + const onUpdateOverflow = jest.fn(); + const options = createObserveOptions({ onUpdateOverflow }); + const manager = createOverflowManager(options); + const container = createContainer(100); + const itemA = createElementWithSize('button', 40); + const itemB = createElementWithSize('button', 40); + const menu = createElementWithSize('button', 20); + + manager.addItem({ element: itemA, id: 'a', priority: 1 }); + manager.addItem({ element: itemB, id: 'b', priority: 0 }); + manager.addOverflowMenu(menu); + manager.observe(container); + manager.forceUpdate(); + + 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']); + }); + + 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); + const container = createContainer(100); + const itemA = createElementWithSize('button', 40); + + manager.addItem({ element: itemA, id: 'a', priority: 1 }); + manager.observe(container, options); + manager.forceUpdate(); + + onUpdateOverflow.mockClear(); + manager.setOptions({ padding: 10 }); // padding is already 10; no real change + + expect(onUpdateOverflow).not.toHaveBeenCalled(); + }); + + it('disconnect stops observation and re-observe restarts dispatching', () => { + const onUpdateOverflow = jest.fn(); + const options = createObserveOptions({ onUpdateOverflow }); + const manager = createOverflowManager(options); + 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']); + + 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']); + }); + + it('should remove items through removeItem', () => { + const onUpdateOverflow = jest.fn(); + const options = createObserveOptions({ onUpdateOverflow }); + const manager = createOverflowManager(options); + 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']); + + manager.removeItem('a'); + manager.forceUpdate(); + + const dispatch = lastDispatch(onUpdateOverflow); + expect(dispatch.visibleItems).toEqual([]); + expect(dispatch.invisibleItems).toEqual([]); + }); +}); diff --git a/packages/react-components/priority-overflow/src/overflowManager.ts b/packages/react-components/priority-overflow/src/overflowManager.ts index babf1954e60bde..c41f43617cb099 100644 --- a/packages/react-components/priority-overflow/src/overflowManager.ts +++ b/packages/react-components/priority-overflow/src/overflowManager.ts @@ -11,11 +11,28 @@ import type { OverflowDividerEntry, } from './types'; +const DEFAULT_OPTIONS: Required = { + overflowAxis: 'horizontal', + overflowDirection: 'end', + padding: 10, + minimumVisible: 0, + hasHiddenItems: false, + onUpdateItemVisibility: () => { + /* noop */ + }, + onUpdateOverflow: () => { + /* noop */ + }, +}; + /** + * Creates an overflow manager instance for a single container. + * * @internal + * @param initialOptions - Initial observe options. Missing values are filled with defaults. * @returns overflow manager instance */ -export function createOverflowManager(): OverflowManager { +export function createOverflowManager(initialOptions: Partial = {}): OverflowManager { // calls to `offsetWidth or offsetHeight` can happen multiple times in an update // Use a cache to avoid causing too many recalcs and avoid scripting time to meausure sizes const sizeCache = new Map(); @@ -26,19 +43,12 @@ export function createOverflowManager(): OverflowManager { // If true, next update will dispatch to onUpdateOverflow even if queue top states don't change // Initially true to force dispatch on first mount let forceDispatch = true; - const options: Required = { - padding: 10, - overflowAxis: 'horizontal', - overflowDirection: 'end', - minimumVisible: 0, - onUpdateItemVisibility: () => undefined, - onUpdateOverflow: () => undefined, - hasHiddenItems: false, - }; - + const options: Required = { ...DEFAULT_OPTIONS, ...initialOptions }; const overflowItems: Record = {}; const overflowDividers: Record = {}; - let disposeResizeObserver: () => void = () => null; + let disposeResizeObserver: () => void = () => { + /* noop */ + }; const getNextItem = (queueToDequeue: PriorityQueue, queueToEnqueue: PriorityQueue) => { const nextItem = queueToDequeue.dequeue(); @@ -205,27 +215,70 @@ export function createOverflowManager(): OverflowManager { const update: OverflowManager['update'] = debounce(forceUpdate); + const setOptions: OverflowManager['setOptions'] = nextOptions => { + if (options === nextOptions) { + return; + } + + const shouldTriggerUpdate = + (nextOptions.overflowAxis && options.overflowAxis !== nextOptions.overflowAxis) || + (nextOptions.overflowDirection && options.overflowDirection !== nextOptions.overflowDirection) || + (nextOptions.padding && options.padding !== nextOptions.padding) || + (nextOptions.minimumVisible && options.minimumVisible !== nextOptions.minimumVisible) || + (nextOptions.hasHiddenItems && options.hasHiddenItems !== nextOptions.hasHiddenItems); + + Object.assign(options, nextOptions); + + if (shouldTriggerUpdate) { + forceDispatch = true; + update(); + } + }; + const observe: OverflowManager['observe'] = (observedContainer, userOptions) => { - Object.assign(options, userOptions); - observing = true; - Object.values(overflowItems).forEach(item => visibleItemQueue.enqueue(item.id)); + if (userOptions) { + Object.assign(options, userOptions); + } + Object.values(overflowItems).forEach(item => { + if (!visibleItemQueue.contains(item.id) && !invisibleItemQueue.contains(item.id)) { + visibleItemQueue.enqueue(item.id); + } + }); container = observedContainer; + observing = true; disposeResizeObserver = observeResize(container, entries => { if (!entries[0] || !container) { return; } - update(); }); }; - const addItem: OverflowManager['addItem'] = item => { - if (overflowItems[item.id]) { + const disconnect: OverflowManager['disconnect'] = () => { + disposeResizeObserver(); + disposeResizeObserver = () => { + /* noop */ + }; + + // reset flags + container = undefined; + observing = false; + forceDispatch = true; + + // clear all entries + Object.keys(overflowItems).forEach(itemId => removeItem(itemId)); + Object.keys(overflowDividers).forEach(dividerId => removeDivider(dividerId)); + removeOverflowMenu(); + sizeCache.clear(); + }; + + const addItem: OverflowManager['addItem'] = items => { + if (overflowItems[items.id]) { return; } - overflowItems[item.id] = item; + overflowItems[items.id] = items; // some options can affect priority which are only set on `observe` if (observing) { @@ -233,15 +286,14 @@ export function createOverflowManager(): OverflowManager { // i.e. new element is enqueued but the top of the queue stays the same // force a dispatch on the next batched update forceDispatch = true; - visibleItemQueue.enqueue(item.id); + visibleItemQueue.enqueue(items.id); + update(); } - if (item.groupId) { - groupManager.addItem(item.id, item.groupId); - item.element.setAttribute(DATA_OVERFLOW_GROUP, item.groupId); + if (items.groupId) { + groupManager.addItem(items.id, items.groupId); + items.element.setAttribute(DATA_OVERFLOW_GROUP, items.groupId); } - - update(); }; const addOverflowMenu: OverflowManager['addOverflowMenu'] = el => { @@ -294,22 +346,9 @@ export function createOverflowManager(): OverflowManager { sizeCache.delete(item.element); delete overflowItems[itemId]; - update(); - }; - - const disconnect: OverflowManager['disconnect'] = () => { - disposeResizeObserver(); - - // reset flags - container = undefined; - observing = false; - forceDispatch = true; - - // clear all entries - Object.keys(overflowItems).forEach(itemId => removeItem(itemId)); - Object.keys(overflowDividers).forEach(dividerId => removeDivider(dividerId)); - removeOverflowMenu(); - sizeCache.clear(); + if (observing) { + update(); + } }; return { @@ -323,6 +362,7 @@ export function createOverflowManager(): OverflowManager { removeOverflowMenu, addDivider, removeDivider, + setOptions, }; } diff --git a/packages/react-components/priority-overflow/src/types.ts b/packages/react-components/priority-overflow/src/types.ts index c3760795ca4171..51faa5a5ee4fd7 100644 --- a/packages/react-components/priority-overflow/src/types.ts +++ b/packages/react-components/priority-overflow/src/types.ts @@ -1,21 +1,39 @@ +/** + * Direction where items are removed when overflow occurs. + */ export type OverflowDirection = 'start' | 'end'; + +/** + * Axis used to measure overflow. + */ export type OverflowAxis = 'horizontal' | 'vertical'; + +/** + * Visibility state for an overflow group. + */ export type OverflowGroupState = 'visible' | 'hidden' | 'overflow'; + +/** + * Tracked item in the overflow manager. + */ export interface OverflowItemEntry { /** - * HTML element that will be disappear when overflowed + * HTML element that disappears when the item overflows. */ element: HTMLElement; /** - * Lower priority items are invisible first when the container is overflowed + * Lower-priority items become invisible first when the container overflows. * @default 0 */ priority: number; /** - * Specific id, used to track visibility and provide updates to consumers + * Stable item id used to track visibility and emit updates. */ id: string; + /** + * Optional group id used to coordinate divider and grouped visibility states. + */ groupId?: string; /** @@ -26,90 +44,129 @@ export interface OverflowItemEntry { pinned?: boolean; } +/** + * Tracked divider in the overflow manager. + */ export interface OverflowDividerEntry { /** - * HTML element that will disappear when overflowed + * HTML element that disappears when its group overflows. */ element: HTMLElement; + /** + * Id of the group controlled by this divider. + */ groupId: string; } /** - * signature similar to standard event listeners, but typed to handle the custom event + * Signature similar to standard event listeners, typed for overflow updates. */ export type OnUpdateOverflow = (data: OverflowEventPayload) => void; +/** + * Callback invoked when a single item's visibility changes. + */ export type OnUpdateItemVisibility = (data: OnUpdateItemVisibilityPayload) => void; /** * Payload of the custom DOM event for overflow updates */ export interface OverflowEventPayload { + /** + * Items currently visible in the container. + */ visibleItems: OverflowItemEntry[]; + + /** + * Items currently moved to overflow. + */ invisibleItems: OverflowItemEntry[]; + + /** + * Current visibility state by group id. + */ groupVisibility: Record; } +/** + * Payload for item-level visibility updates. + */ export interface OnUpdateItemVisibilityPayload { + /** + * Item whose visibility changed. + */ item: OverflowItemEntry; + + /** + * Whether the item is now visible. + */ visible: boolean; } +/** + * Options used to initialize or reconfigure overflow observation. + */ export interface ObserveOptions { /** - * Padding (in px) at the end of the container before overflow occurs - * Useful to account for extra elements (i.e. dropdown menu) - * or to account for any kinds of margins between items which are hard to measure with JS + * Padding in pixels reserved at the end of the container before overflow occurs. + * Useful for accounting for extra elements (for example an overflow menu button) + * or margins between items that are difficult to measure in JavaScript. * @default 10 */ padding?: number; /** - * Direction where items are removed when overflow occurs + * Direction where items are removed when overflow occurs. * @default end */ overflowDirection?: OverflowDirection; /** - * Horizontal or vertical overflow + * Overflow axis used for size measurement. * @default horizontal */ overflowAxis?: OverflowAxis; /** - * The minimum number of visible items + * Minimum number of items that must remain visible. */ minimumVisible?: number; /** - * Callback when item visibility is updated + * Callback invoked when an individual item's visibility changes. */ onUpdateItemVisibility: OnUpdateItemVisibility; /** - * Callback when item visibility is updated + * Callback invoked after overflow state is recomputed. */ onUpdateOverflow: OnUpdateOverflow; /** - * When true, the overflow menu has default hidden items + * When true, reserve space as if the overflow menu were visible even with no overflowing items. * @default false */ hasHiddenItems?: boolean; } /** + * Internal manager contract used to observe and compute priority overflow. + * * @internal */ export interface OverflowManager { /** * Starts observing the container and managing the overflow state */ - observe: (container: HTMLElement, options: ObserveOptions) => void; + observe: (container: HTMLElement, options?: ObserveOptions) => void; /** * Stops observing the container */ disconnect: () => void; + /** + * Updates engine options without restarting observation. + */ + setOptions: (options: Partial) => void; /** * Add overflow items */ diff --git a/packages/react-components/react-overflow/library/src/useOverflowContainer.test.ts b/packages/react-components/react-overflow/library/src/useOverflowContainer.test.tsx similarity index 60% rename from packages/react-components/react-overflow/library/src/useOverflowContainer.test.ts rename to packages/react-components/react-overflow/library/src/useOverflowContainer.test.tsx index fdb02fb5711663..d6badfedd4d649 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowContainer.test.ts +++ b/packages/react-components/react-overflow/library/src/useOverflowContainer.test.tsx @@ -1,8 +1,9 @@ -import type * as React from 'react'; +import * as React from 'react'; import type { OverflowAxis, OverflowManager } from '@fluentui/priority-overflow'; import { createOverflowManager } from '@fluentui/priority-overflow'; -import { useOverflowContainer } from './useOverflowContainer'; +import { render } from '@testing-library/react'; import { renderHook } from '@testing-library/react-hooks'; +import { useOverflowContainer } from './useOverflowContainer'; jest.mock('@fluentui/priority-overflow'); @@ -18,6 +19,7 @@ const mockOverflowManager = (options: Partial = {}) => { update: jest.fn(), addDivider: jest.fn(), removeDivider: jest.fn(), + setOptions: jest.fn(), }; (createOverflowManager as jest.Mock).mockReturnValue({ ...defaultMock, @@ -26,10 +28,22 @@ const mockOverflowManager = (options: Partial = {}) => { }; describe('useOverflowContainer', () => { - it('should create overflow manager', () => { + beforeEach(() => { + (createOverflowManager as jest.Mock).mockReset(); + mockOverflowManager(); + }); + + it('should create overflow manager with initial options', () => { renderHook(() => useOverflowContainer(() => undefined, { onUpdateItemVisibility: () => undefined })); expect(createOverflowManager).toHaveBeenCalledTimes(1); + expect((createOverflowManager as jest.Mock).mock.calls[0][0]).toMatchObject({ + hasHiddenItems: false, + minimumVisible: 0, + overflowAxis: 'horizontal', + overflowDirection: 'end', + padding: 10, + }); }); it('should add to overflow manager when registering item', () => { @@ -61,30 +75,57 @@ describe('useOverflowContainer', () => { expect(removeItemMock).toHaveBeenCalledWith(overflowItem.id); }); - it('should call observe with default options', () => { + it('should call observe with the container element', () => { const observeMock = jest.fn(); mockOverflowManager({ observe: observeMock }); - const { result, rerender } = renderHook(() => { - return useOverflowContainer(() => undefined, { onUpdateItemVisibility: () => undefined }); - }); - // eslint-disable-next-line @typescript-eslint/no-deprecated - (result.current.containerRef as React.MutableRefObject).current = document.createElement('div'); - rerender(); + + const TestComponent: React.FC = () => { + const { containerRef } = useOverflowContainer(() => undefined, { + onUpdateItemVisibility: () => undefined, + }); + return
; + }; + + const { getByTestId } = render(); expect(observeMock).toHaveBeenCalledTimes(1); - expect(observeMock.mock.calls[0]).toMatchInlineSnapshot(` - Array [ -
, - Object { - "hasHiddenItems": false, - "minimumVisible": 0, - "onUpdateItemVisibility": [Function], - "onUpdateOverflow": [Function], - "overflowAxis": "horizontal", - "overflowDirection": "end", - "padding": 10, - }, - ] - `); + expect(observeMock).toHaveBeenCalledWith(getByTestId('container')); + }); + + it('should disconnect on unmount', () => { + const disconnectMock = jest.fn(); + const observeMock = jest.fn(); + mockOverflowManager({ observe: observeMock, disconnect: disconnectMock }); + + const TestComponent: React.FC = () => { + const { containerRef } = useOverflowContainer(() => undefined, { + onUpdateItemVisibility: () => undefined, + }); + return
; + }; + + const { unmount } = render(); + expect(disconnectMock).not.toHaveBeenCalled(); + unmount(); + expect(disconnectMock).toHaveBeenCalledTimes(1); + }); + + it('should call setOptions when options change', () => { + const setOptionsMock = jest.fn(); + mockOverflowManager({ setOptions: setOptionsMock }); + + let overflowAxis: OverflowAxis = 'horizontal'; + const { rerender } = renderHook(() => + useOverflowContainer(() => undefined, { onUpdateItemVisibility: () => undefined, overflowAxis }), + ); + + setOptionsMock.mockClear(); + overflowAxis = 'vertical'; + rerender(); + + expect(setOptionsMock).toHaveBeenCalled(); + expect(setOptionsMock.mock.calls[setOptionsMock.mock.calls.length - 1][0]).toMatchObject({ + overflowAxis: 'vertical', + }); }); it('should invoke updateOverflow on overflow manager', () => { @@ -100,7 +141,6 @@ describe('useOverflowContainer', () => { }); it('should not re-render on first mount', () => { - mockOverflowManager(); let renderCount = 0; renderHook(() => { renderCount++; @@ -109,21 +149,4 @@ describe('useOverflowContainer', () => { expect(renderCount).toEqual(1); }); - - it('should re-render when option changes', () => { - let overflowAxis: OverflowAxis = 'horizontal'; - mockOverflowManager(); - let renderCount = 0; - const { rerender } = renderHook(() => { - renderCount++; - return useOverflowContainer(() => undefined, { onUpdateItemVisibility: () => undefined, overflowAxis }); - }); - - expect(renderCount).toEqual(1); - - overflowAxis = 'vertical'; - rerender(); - - expect(renderCount).toEqual(2); - }); }); diff --git a/packages/react-components/react-overflow/library/src/useOverflowContainer.ts b/packages/react-components/react-overflow/library/src/useOverflowContainer.ts index 071f51d2e7a4c1..54fcd01abe93ad 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowContainer.ts +++ b/packages/react-components/react-overflow/library/src/useOverflowContainer.ts @@ -14,12 +14,10 @@ import type { OverflowManager, ObserveOptions, } from '@fluentui/priority-overflow'; -import { canUseDOM, useEventCallback, useFirstMount, useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; +import { canUseDOM, useEventCallback, useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; import type { UseOverflowContainerReturn } from './types'; import { DATA_OVERFLOWING, DATA_OVERFLOW_DIVIDER, DATA_OVERFLOW_ITEM, DATA_OVERFLOW_MENU } from './constants'; -const noop = () => null; - /** * @internal * @param update - Callback when overflow state changes @@ -42,20 +40,21 @@ export const useOverflowContainer = ( } = options; const onUpdateOverflow = useEventCallback(update); + const onUpdateItemVisibilityCallback = useEventCallback(onUpdateItemVisibility); - const overflowOptions = React.useMemo( + const observeOptions: Required = React.useMemo( () => ({ overflowAxis, overflowDirection, padding, minimumVisible, - onUpdateItemVisibility, + onUpdateItemVisibility: onUpdateItemVisibilityCallback, onUpdateOverflow, hasHiddenItems, }), [ minimumVisible, - onUpdateItemVisibility, + onUpdateItemVisibilityCallback, overflowAxis, overflowDirection, padding, @@ -64,86 +63,60 @@ export const useOverflowContainer = ( ], ); - const firstMount = useFirstMount(); - - // DOM ref to the overflow container element const containerRef = React.useRef(null); - const [overflowManager, setOverflowManager] = React.useState(() => - canUseDOM() ? createOverflowManager() : null, - ); + const managerRef = React.useRef(null); - // On first mount there is no need to create an overflow manager and re-render - useIsomorphicLayoutEffect(() => { - if (firstMount && containerRef.current) { - overflowManager?.observe(containerRef.current, overflowOptions); - } - }, [firstMount, overflowManager, overflowOptions]); + if (managerRef.current === null) { + managerRef.current = canUseDOM() ? createOverflowManager(observeOptions) : null; + } useIsomorphicLayoutEffect(() => { - if (!containerRef.current || !canUseDOM() || firstMount) { - return; + if (managerRef.current && containerRef.current) { + managerRef.current.observe(containerRef.current); + return () => managerRef.current?.disconnect(); } + }, []); - const newOverflowManager = createOverflowManager(); - newOverflowManager.observe(containerRef.current, overflowOptions); - setOverflowManager(newOverflowManager); - // We don't want to re-create the overflow manager when the first mount flag changes from true to false - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [overflowOptions]); - - /* Clean up overflow manager on unmount */ - React.useEffect( - () => () => { - overflowManager?.disconnect(); - }, - [overflowManager], - ); - - const registerItem = React.useCallback( - (item: OverflowItemEntry) => { - overflowManager?.addItem(item); - item.element.setAttribute(DATA_OVERFLOW_ITEM, ''); - - return () => { - item.element.removeAttribute(DATA_OVERFLOWING); - item.element.removeAttribute(DATA_OVERFLOW_ITEM); - overflowManager?.removeItem(item.id); - }; - }, - [overflowManager], - ); - - const registerDivider = React.useCallback( - (divider: OverflowDividerEntry) => { - const el = divider.element; - overflowManager?.addDivider(divider); - el.setAttribute(DATA_OVERFLOW_DIVIDER, ''); - - return () => { - divider.groupId && overflowManager?.removeDivider(divider.groupId); - el.removeAttribute(DATA_OVERFLOW_DIVIDER); - }; - }, - [overflowManager], - ); - - const registerOverflowMenu = React.useCallback( - (el: HTMLElement) => { - overflowManager?.addOverflowMenu(el); - el.setAttribute(DATA_OVERFLOW_MENU, ''); - - return () => { - overflowManager?.removeOverflowMenu(); - el.removeAttribute(DATA_OVERFLOW_MENU); - }; - }, - [overflowManager], - ); + useIsomorphicLayoutEffect(() => { + managerRef.current?.setOptions(observeOptions); + }, [observeOptions]); + + const registerItem = React.useCallback((item: OverflowItemEntry) => { + managerRef.current?.addItem(item); + item.element.setAttribute(DATA_OVERFLOW_ITEM, ''); + + return () => { + item.element.removeAttribute(DATA_OVERFLOWING); + item.element.removeAttribute(DATA_OVERFLOW_ITEM); + managerRef.current?.removeItem(item.id); + }; + }, []); + + const registerDivider = React.useCallback((divider: OverflowDividerEntry) => { + const el = divider.element; + managerRef.current?.addDivider(divider); + el.setAttribute(DATA_OVERFLOW_DIVIDER, ''); + + return () => { + managerRef.current?.removeDivider(divider.groupId); + el.removeAttribute(DATA_OVERFLOW_DIVIDER); + }; + }, []); + + const registerOverflowMenu = React.useCallback((el: HTMLElement) => { + managerRef.current?.addOverflowMenu(el); + el.setAttribute(DATA_OVERFLOW_MENU, ''); + + return () => { + managerRef.current?.removeOverflowMenu(); + el.removeAttribute(DATA_OVERFLOW_MENU); + }; + }, []); const updateOverflow = React.useCallback(() => { - overflowManager?.update(); - }, [overflowManager]); + managerRef.current?.update(); + }, []); return { registerItem, @@ -154,6 +127,10 @@ export const useOverflowContainer = ( }; }; +const noop = () => { + /* noop */ +}; + export const updateVisibilityAttribute: OnUpdateItemVisibility = ({ item, visible }) => { if (visible) { item.element.removeAttribute(DATA_OVERFLOWING);