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..6a6782cb85ea2d 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,70 +18,67 @@ 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; + addItem: (item: OverflowItemEntry) => void; addOverflowMenu: (element: HTMLElement) => void; disconnect: () => void; forceUpdate: () => void; - observe: (container: HTMLElement, options: ObserveOptions) => void; + getSnapshot: () => OverflowEventPayload; + observe: (container: HTMLElement) => () => void; removeDivider: (groupId: string) => void; removeItem: (itemId: string) => void; removeOverflowMenu: () => void; + setOptions: (options: Partial) => void; + subscribe: (listener: () => void) => () => void; update: () => void; } -// (No @packageDocumentation comment for this package) +// @public +export type OverflowManagerOptions = ObserveOptions; ``` diff --git a/packages/react-components/priority-overflow/src/index.ts b/packages/react-components/priority-overflow/src/index.ts index ba4668ceab6d56..73e9410453b500 100644 --- a/packages/react-components/priority-overflow/src/index.ts +++ b/packages/react-components/priority-overflow/src/index.ts @@ -1,6 +1,12 @@ +/** + * Utilities for measuring container overflow and managing priority-based item visibility. + * + * @packageDocumentation + */ export { createOverflowManager } from './overflowManager'; export type { ObserveOptions, + OverflowManagerOptions, OnUpdateItemVisibility, OnUpdateItemVisibilityPayload, OnUpdateOverflow, 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..337817257bf199 --- /dev/null +++ b/packages/react-components/priority-overflow/src/overflowManager.test.ts @@ -0,0 +1,147 @@ +import { createOverflowManager } from './overflowManager'; +import type { ObserveOptions } 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 getVisibleIds = (manager: ReturnType) => + manager + .getSnapshot() + .visibleItems.map(item => item.id) + .sort(); + + const getInvisibleIds = (manager: ReturnType) => + manager + .getSnapshot() + .invisibleItems.map(item => item.id) + .sort(); + + it('should expose a stable snapshot after forceUpdate', () => { + const manager = createOverflowManager(); + const container = createContainer(100); + const itemA = createElementWithSize('button', 40); + const itemB = createElementWithSize('button', 40); + const menu = createElementWithSize('button', 20); + + manager.setOptions(createObserveOptions()); + manager.addItem({ element: itemA, id: 'a', priority: 1 }); + manager.addItem({ element: itemB, id: 'b', priority: 0 }); + manager.addOverflowMenu(menu); + manager.observe(container); + manager.forceUpdate(); + + expect(getVisibleIds(manager)).toEqual(['a', 'b']); + expect(getInvisibleIds(manager)).toEqual([]); + expect(manager.getSnapshot().groupVisibility).toEqual({}); + }); + + it('should update snapshot and notify subscribers when options change', () => { + const manager = createOverflowManager(); + const container = createContainer(100); + const itemA = createElementWithSize('button', 40); + const itemB = createElementWithSize('button', 40); + const menu = createElementWithSize('button', 20); + const listener = jest.fn(); + + manager.setOptions(createObserveOptions()); + 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); + + manager.setOptions({ padding: 30 }); + + expect(listener).toHaveBeenCalled(); + expect(getVisibleIds(manager)).toEqual(['a']); + expect(getInvisibleIds(manager)).toEqual(['b']); + expect(manager.getSnapshot().groupVisibility).toEqual({}); + + unsubscribe(); + }); + + it('should reset snapshot state when observation cleanup runs', () => { + const manager = createOverflowManager(); + const container = createContainer(100); + const item = createElementWithSize('button', 40); + + manager.setOptions(createObserveOptions()); + manager.addItem({ element: item, id: 'a', priority: 1 }); + const cleanup = manager.observe(container); + manager.forceUpdate(); + + expect(getVisibleIds(manager)).toEqual(['a']); + + cleanup(); + + expect(manager.getSnapshot()).toEqual({ + visibleItems: [], + invisibleItems: [], + groupVisibility: {}, + }); + }); + + it('should remove items through removeItem', () => { + const manager = createOverflowManager(); + const container = createContainer(100); + const item = createElementWithSize('button', 40); + + manager.setOptions(createObserveOptions()); + manager.addItem({ element: item, id: 'a', priority: 1 }); + manager.observe(container); + manager.forceUpdate(); + + expect(getVisibleIds(manager)).toEqual(['a']); + + manager.removeItem('a'); + manager.forceUpdate(); + + 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 babf1954e60bde..420ed22ca6a845 100644 --- a/packages/react-components/priority-overflow/src/overflowManager.ts +++ b/packages/react-components/priority-overflow/src/overflowManager.ts @@ -9,13 +9,27 @@ import type { OverflowManager, ObserveOptions, OverflowDividerEntry, + OverflowEventPayload, } from './types'; +const DEFAULT_OPTIONS: Required = { + overflowAxis: 'horizontal', + overflowDirection: 'end', + padding: 10, + minimumVisible: 0, + hasHiddenItems: false, + onUpdateItemVisibility: () => null, + onUpdateOverflow: () => null, +}; + /** + * 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,20 +40,23 @@ 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 = {}; + const listeners = new Set<() => void>(); let disposeResizeObserver: () => void = () => null; + 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); @@ -149,9 +166,15 @@ export function createOverflowManager(): OverflowManager { 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; @@ -205,12 +228,16 @@ export function createOverflowManager(): OverflowManager { const update: OverflowManager['update'] = debounce(forceUpdate); - const observe: OverflowManager['observe'] = (observedContainer, userOptions) => { - Object.assign(options, userOptions); - observing = true; - Object.values(overflowItems).forEach(item => visibleItemQueue.enqueue(item.id)); + const reconnectObserver = () => { + disposeResizeObserver(); + disposeResizeObserver = () => null; - container = observedContainer; + if (!container) { + observing = false; + return; + } + + observing = true; disposeResizeObserver = observeResize(container, entries => { if (!entries[0] || !container) { return; @@ -220,7 +247,82 @@ export function createOverflowManager(): OverflowManager { }); }; - const addItem: OverflowManager['addItem'] = item => { + const setOptions: OverflowManager['setOptions'] = nextOptions => { + if (options === nextOptions) return; + const previousAxis = options.overflowAxis; + const previousDirection = options.overflowDirection; + const previousPadding = options.padding; + const previousMinimumVisible = options.minimumVisible; + const previousHasHiddenItems = options.hasHiddenItems; + + Object.assign(options, nextOptions); + + if ( + previousAxis !== options.overflowAxis || + previousDirection !== options.overflowDirection || + previousPadding !== options.padding || + previousMinimumVisible !== options.minimumVisible || + previousHasHiddenItems !== options.hasHiddenItems + ) { + forceDispatch = true; + update(); + } + }; + + const connectContainer = (nextContainer: HTMLElement | null) => { + if (container === nextContainer) { + return; + } + + container = nextContainer ?? undefined; + reconnectObserver(); + + if (container) { + Object.values(overflowItems).forEach(item => { + if (!visibleItemQueue.contains(item.id) && !invisibleItemQueue.contains(item.id)) { + visibleItemQueue.enqueue(item.id); + } + }); + forceDispatch = true; + update(); + } + }; + + let observeCleanup: () => void = () => null; + + const observe: OverflowManager['observe'] = observedContainer => { + Object.values(overflowItems).forEach(item => { + if (!visibleItemQueue.contains(item.id) && !invisibleItemQueue.contains(item.id)) { + visibleItemQueue.enqueue(item.id); + } + }); + + connectContainer(observedContainer); + + const cleanup = () => { + if (container !== observedContainer) { + return; + } + + disposeResizeObserver(); + disposeResizeObserver = () => null; + container = undefined; + observing = false; + forceDispatch = true; + takeSnapshot({ + visibleItems: [], + invisibleItems: [], + groupVisibility: {}, + }); + }; + + observeCleanup = cleanup; + return cleanup; + }; + + const disconnect: OverflowManager['disconnect'] = () => observeCleanup(); + + const addItem = (item: OverflowItemEntry) => { if (overflowItems[item.id]) { return; } @@ -234,21 +336,25 @@ export function createOverflowManager(): OverflowManager { // force a dispatch on the next batched update forceDispatch = true; visibleItemQueue.enqueue(item.id); + update(); } if (item.groupId) { groupManager.addItem(item.id, item.groupId); item.element.setAttribute(DATA_OVERFLOW_GROUP, item.groupId); } - - update(); }; - const addOverflowMenu: OverflowManager['addOverflowMenu'] = el => { + const addOverflowMenu = (el: HTMLElement) => { overflowMenu = el; + + if (observing) { + forceDispatch = true; + update(); + } }; - const addDivider: OverflowManager['addDivider'] = divider => { + const addDivider = (divider: OverflowDividerEntry) => { if (!divider.groupId || overflowDividers[divider.groupId]) { return; } @@ -257,11 +363,16 @@ export function createOverflowManager(): OverflowManager { overflowDividers[divider.groupId] = divider; }; - const removeOverflowMenu: OverflowManager['removeOverflowMenu'] = () => { + const removeOverflowMenu = () => { overflowMenu = undefined; + + if (observing) { + forceDispatch = true; + update(); + } }; - const removeDivider: OverflowManager['removeDivider'] = groupId => { + const removeDivider = (groupId: string) => { if (!overflowDividers[groupId]) { return; } @@ -294,35 +405,33 @@ export function createOverflowManager(): OverflowManager { sizeCache.delete(item.element); delete overflowItems[itemId]; - update(); + if (observing) { + update(); + } }; - const disconnect: OverflowManager['disconnect'] = () => { - disposeResizeObserver(); - - // reset flags - container = undefined; - observing = false; - forceDispatch = true; + const subscribe: OverflowManager['subscribe'] = listener => { + listeners.add(listener); - // clear all entries - Object.keys(overflowItems).forEach(itemId => removeItem(itemId)); - Object.keys(overflowDividers).forEach(dividerId => removeDivider(dividerId)); - removeOverflowMenu(); - sizeCache.clear(); + return () => { + listeners.delete(listener); + }; }; return { + addDivider, addItem, + addOverflowMenu, disconnect, forceUpdate, + getSnapshot, observe, + removeDivider, removeItem, - update, - addOverflowMenu, removeOverflowMenu, - addDivider, - removeDivider, + setOptions, + subscribe, + update, }; } diff --git a/packages/react-components/priority-overflow/src/types.ts b/packages/react-components/priority-overflow/src/types.ts index c3760795ca4171..02e8439ffea389 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,125 +44,182 @@ 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; } /** + * Runtime options accepted by `setOptions`. + */ +export type OverflowManagerOptions = ObserveOptions; + +/** + * Internal manager contract used to observe and compute priority overflow. + * * @internal */ export interface OverflowManager { /** - * Starts observing the container and managing the overflow state + * Updates engine options without requiring full observation re-creation. */ - observe: (container: HTMLElement, options: ObserveOptions) => void; + setOptions: (options: Partial) => void; /** - * Stops observing the container + * Starts observing the container and managing overflow state. */ - disconnect: () => void; + observe: (container: HTMLElement) => () => void; /** - * Add overflow items + * Adds an item to overflow tracking. */ - addItem: (items: OverflowItemEntry) => void; + addItem: (item: OverflowItemEntry) => void; + /** - * Remove overflow item + * Removes an overflow item by id. */ removeItem: (itemId: string) => void; /** - * Manually update the overflow, updates are batched and async + * Schedules an asynchronous overflow recomputation. */ update: () => void; /** - * Manually update the overflow sync + * Forces an immediate synchronous overflow recomputation. */ forceUpdate: () => void; /** - * Adds an element that opens an overflow menu. This is used to calculate - * available space and check if additional items need to overflow + * Attaches the overflow menu element. + * This is used to calculate available space and determine whether more items should overflow. */ addOverflowMenu: (element: HTMLElement) => void; /** - * Add overflow divider + * Removes the overflow menu element. + */ + removeOverflowMenu: () => void; + + /** + * Adds a divider for the provided group. */ addDivider: (divider: OverflowDividerEntry) => void; /** - * Remove overflow divider + * Removes a divider by group id. */ removeDivider: (groupId: string) => void; /** - * Unsets the overflow menu element + * Returns the current canonical overflow snapshot. */ - removeOverflowMenu: () => void; + getSnapshot: () => OverflowEventPayload; + + /** + * Subscribes to snapshot changes. + */ + subscribe: (listener: () => void) => () => void; + + /** + * Disconnects all active observers. + * Equivalent to calling the latest cleanup function returned by `observe`. + */ + disconnect: () => void; } diff --git a/packages/react-components/react-overflow/library/README.md b/packages/react-components/react-overflow/library/README.md index 2ef4d3e7c876cb..dc84b62f4d7b14 100644 --- a/packages/react-components/react-overflow/library/README.md +++ b/packages/react-components/react-overflow/library/README.md @@ -3,3 +3,9 @@ **React Priority Overflow components for [Fluent UI React](https://react.fluentui.dev)** These are not production-ready components and **should never be used in product**. This space is useful for testing new components whose APIs might change before final release. + +For internal notes on overflow, keep the structure intentionally small: + +- [docs/overflow-algorithm.md](./docs/overflow-algorithm.md) — engine spec +- [docs/react-overflow-react-bridge.md](./docs/react-overflow-react-bridge.md) — React bridge spec +- [docs/overflow-northstar.md](./docs/overflow-northstar.md) — temporary working document for future changes diff --git a/packages/react-components/react-overflow/library/docs/overflow-algorithm.md b/packages/react-components/react-overflow/library/docs/overflow-algorithm.md new file mode 100644 index 00000000000000..258d5901ea24a4 --- /dev/null +++ b/packages/react-components/react-overflow/library/docs/overflow-algorithm.md @@ -0,0 +1,1383 @@ +# Priority Overflow Engine Spec + +This document is the working specification for the `@fluentui/priority-overflow` engine that sits underneath `@fluentui/react-overflow`. + +It is intentionally focused on the engine itself: queues, measurement, lifecycle, invariants, and browser cost. + +## Scope + +- Engine package: `packages/react-components/priority-overflow/src/` +- Main engine entry point: `createOverflowManager()` + +For the React integration layer, see `docs/react-overflow-react-bridge.md`. + +## One-sentence model + +The engine keeps two priority queues of item ids, measures the observed container and registered elements, then repeatedly moves items between the visible and invisible queues until occupied size fits within available size while publishing a canonical snapshot and optional callbacks. + +## Core concepts + +### Overflow item + +An overflow item is a registered DOM element with: + +- `id`: stable identity +- `priority`: lower priority hides earlier +- `pinned`: never overflow +- `groupId`: optional grouping key + +### Overflow menu + +An optional DOM element whose size is only counted when at least one item is hidden, or when `hasHiddenItems` is true. This lets the engine reserve room for a "more" button. + +### Divider + +An optional group-owned element whose visibility follows the group. Dividers are counted in occupied size only when the group is at least partially visible. + +### Visible and invisible queues + +The engine stores ids in two heaps: + +- `visibleItemQueue`: the next candidate to hide is at the top +- `invisibleItemQueue`: the next candidate to show is at the top + +The comparator uses three rules: + +1. Pinned items rank above non-pinned items. +2. Higher `priority` ranks above lower `priority`. +3. Ties are broken by DOM order using `compareDocumentPosition`, with `overflowDirection` deciding whether the start or end of the container hides first. + +## Flow overview + +```mermaid +flowchart TD + A[Create manager] --> B[setOptions] + B --> C[Register items, menu, dividers] + C --> D[observe container] + D --> E[ResizeObserver or manual update] + E --> F[Microtask debounce] + F --> G[Read container client size] + G --> H[Read item, divider, and menu sizes with cache] + H --> I[Rebalance queues by priority and fit] + I --> J[Update snapshot and invoke callbacks] + J --> K[Subscribers and wrapper react] +``` + +## Lifecycle + +```mermaid +stateDiagram-v2 + [*] --> Idle + Idle --> Configured: setOptions / registerItem / registerDivider / attachOverflowMenu + Configured --> Observing: observe(container) + Observing --> Dirty: register or unregister / resize / manual update + Dirty --> Scheduled: update() + Scheduled --> Processing: microtask runs forceUpdate() + Processing --> Observing: snapshot and callbacks updated + Observing --> Configured: observation cleanup +``` + +## Detailed lifecycle + +### 1. Construction + +`createOverflowManager()` allocates: + +- the visible and invisible priority queues +- a per-pass size cache `Map` +- item and divider registries +- a group manager for `visible | hidden | overflow` +- mutable options and container references + +At this point, nothing is observed and no DOM reads happen. + +### 2. Configuration + +`setOptions(options)` mutates the live options object independently from observation. + +That means configuration and attachment are separate concerns: + +- options can change without recreating observation +- callback handlers can be replaced incrementally +- observation does not implicitly own all other runtime relationships + +The engine still keeps `onUpdateOverflow` and `onUpdateItemVisibility` in its options object, but they are no longer the only readable output channel because the manager also maintains a canonical `OverflowSnapshot`. + +### 3. Runtime connections + +The manager uses paired setup and cleanup boundaries for its runtime relationships. + +`observe(container)`: + +- stores the container reference +- reconnects the `ResizeObserver` +- enqueues any already-known items that are not yet in either queue +- schedules an update +- returns a cleanup function that detaches observation and resets the current snapshot to empty + +`registerItem(item)`: + +- inserts the item into the registry +- updates group membership when `groupId` exists +- enqueues the item immediately if observation is already active +- schedules an update +- returns a cleanup function that removes the item from queues and registries + +`attachOverflowMenu(element)`: + +- stores the overflow menu reference +- schedules an update +- returns a cleanup function that detaches that same menu element + +`registerDivider(divider)`: + +- stores the divider by `groupId` +- sets the divider's group attribute +- returns a cleanup function that removes the divider registration + +Important: the engine still exposes `removeItem()` as a lower-level removal helper, but the preferred lifecycle model is the cleanup returned from `registerItem()`. + +Current recommendation: + +- keep `removeItem()` as a low-level internal escape hatch for now +- keep cleanup-returning registration as the primary lifecycle model +- only revisit tighter removal semantics if the team later chooses to narrow the internal manager surface further + +### 4. Update scheduling + +`update()` is microtask-debounced in production. Multiple resize and registration events in the same tick collapse into one `forceUpdate()` run. + +That means the lifecycle is usually: + +1. DOM changes happen +2. `ResizeObserver` fires or code calls `update()` +3. a microtask is queued +4. the queued task performs one rebalance pass + +## Initial mount convergence model + +For initial mount, the most practical mental model is: + +- a single-step path in non-overflowing or already-stable cases +- a multi-step path in the initial-overflow edge case, where the overflow menu becomes layout-participating only after the first overflow computation + +The reason is architectural rather than incidental: + +1. the container and items commit first +2. registration and observation schedule the first engine update +3. the first engine pass can compute overflow before the menu itself participates in layout +4. once overflow exists, the menu can mount and participate in size calculation +5. a second engine pass can then settle the final overflow split with menu width included + +Those steps describe the logical convergence path. The important architectural point is that initial overflowing mount is not conceptually a single-step path: menu participation can introduce a second correction step even without any later resize or user interaction. + +At the same time, those logical steps still occur inside the pre-paint mount pipeline for correctness purposes. In other words: + +- the engine may need more than one internal correction step +- but those steps are still expected to collapse into the same visible first paint on the normal initial-mount path + +So the right distinction is not "one step versus two steps". It is: + +- logically multi-step +- visually single-paint-final for correctness on the mount path this design is centered around + +### 5. Processing pass + +`forceUpdate()` calls `processOverflowItems()`. + +The algorithm: + +1. Clears the size cache. +2. Reads available size as `container.clientWidth` or `clientHeight` minus `padding`. +3. Repairs priority ordering if the best hidden item outranks the worst visible item. +4. Runs two show/hide rounds to stabilize the state. +5. Rebuilds the canonical snapshot and notifies listeners when queue tops changed, or when `forceDispatch` was set. + +The two-round design matters when a new item is added as visible by default and the first pass has not yet settled into the best visible/invisible split. + +## Rebalance algorithm + +```mermaid +flowchart TD + A[Start processOverflowItems] --> B[Clear size cache] + B --> C[availableSize = container.clientSize - padding] + C --> D{Best hidden outranks worst visible?} + D -- yes --> E[Hide worst visible] + E --> D + D -- no --> F[Round 1] + F --> G{occupiedSize < availableSize or only one hidden item left?} + G -- yes --> H[Show best hidden] + H --> G + G -- no --> I{occupiedSize > availableSize and visible count > minimumVisible?} + I -- yes --> J{Next visible item pinned?} + J -- yes --> K[Stop hiding] + J -- no --> L[Hide worst visible] + L --> I + I -- no --> M[Round 2] + K --> M + M --> N[Repeat show loop then hide loop] + N --> O[If state changed, dispatch update] +``` + +## Occupied size model + +`occupiedSize()` computes: + +- sum of `offsetWidth` or `offsetHeight` for all visible items +- plus visible dividers for visible or partially visible groups +- plus overflow menu size when any item is hidden, or when `hasHiddenItems` is true + +This is the central fit function: + +$$ +occupied = \sum visibleItems size(item) + \sum activeDividers size(divider) + overflowMenuSize +$$ + +and the container is considered stable when: + +$$ +occupied \le availableSize +$$ + +## Group lifecycle + +Grouping is a thin state machine layered on top of the base queue model. + +Each group tracks two sets: + +- `visibleItemIds` +- `invisibleItemIds` + +Its visibility state is: + +- `visible`: at least one visible item and no hidden items +- `hidden`: no visible items +- `overflow`: both visible and hidden items exist + +Divider behavior is tied to the last visible item in a group: + +- when hiding the last visible item, the divider gets `data-overflowing` +- when showing the first visible item again, the divider becomes visible again + +## Pinning model + +Pinning is not a separate algorithm. It is a comparator rule plus a stop condition: + +- pinned items compare above all non-pinned items +- if the next candidate to hide is pinned, the hide loop stops + +So pinning affects queue ordering but does not add new passes or new DOM reads. + +## Browser pipeline cost model + +The engine does work in three layers: + +### 1. JavaScript / scheduler cost + +This includes: + +- queue enqueue, dequeue, peek, remove +- set and map updates for groups and size cache +- DOM attribute writes for visibility markers +- callback dispatch + +This is the predictable, CPU-bound part. + +### 2. Layout-related cost + +This comes from reading: + +- `container.clientWidth` or `clientHeight` +- item `offsetWidth` or `offsetHeight` +- divider size +- overflow menu size + +These properties can force layout if the DOM or styles are dirty at the time of the read. + +Important nuance: when the update was triggered by `ResizeObserver`, the browser has already performed layout for the size change that caused the observer callback. In that path, many reads are likely to hit already-computed geometry. When updates are triggered directly after DOM writes, reads are more likely to force a synchronous layout. + +### 3. Style, paint, and composite cost + +The engine itself mostly writes attributes such as `data-overflowing`. In the React wrapper, those attributes map to `display: none` for overflowing items. + +That means the engine can cause: + +- style recalculation because selectors depend on attributes +- layout because `display: none` changes box participation +- paint because visible content changed +- sometimes compositing updates, depending on the surrounding page + +There is no animation or transform-based hiding in the base model, so hiding an item is usually a layout-affecting change, not a compositor-only change. + +## Complexity summary + +Let: + +- `n` = number of items +- `g` = number of groups with dividers +- `k` = number of items that change visibility in one pass + +### Heap operations + +- `enqueue` and `dequeue`: $O(\log n)$ +- `peek`: $O(1)$ +- `remove`: $O(n)$ because it uses `indexOf` before heapify + +### Per-pass size computation + +`occupiedSize()` walks: + +- all currently visible items +- all groups to decide visible divider contribution + +So each `occupiedSize()` call is $O(v + g)$ where `v` is the visible item count, worst-case $O(n + g)$. + +### Full rebalance pass + +The show/hide loops can call `occupiedSize()` many times. In the worst case, if many items toggle during one pass, total JS work trends toward quadratic behavior because the algorithm repeatedly recomputes aggregate occupied size from scratch. + +Conservative worst-case bound for a large rebalance: + +$$ +O(k \cdot (n + g) + k \log n) +$$ + +and when `k \approx n`, that behaves like roughly $O(n^2)$ JS work for the pass. + +This is the most important cost characteristic of the base model. + +## Feature cost breakdown + +### Base model cost + +The base model includes: + +- two heaps +- size cache clearing per pass +- repeated aggregate size computation +- queue moves and callback dispatch + +This is where nearly all algorithmic cost lives. + +### Grouping cost + +Grouping adds: + +- one extra map lookup per item move +- one or two `Set` mutations per moved item +- group visibility recomputation for that group, which is $O(1)$ +- divider visibility attribute writes +- divider contribution in `occupiedSize()`, which adds an $O(g)$ scan over groups + +Impact assessment: + +- JS overhead per moved item: low +- additional DOM reads: only divider size when divider is active +- aggregate pass overhead: moderate when `g` is large because every `occupiedSize()` call scans groups + +### Pinning cost + +Pinning adds: + +- one boolean branch inside item comparison +- one boolean guard in the hide loop + +Impact assessment: + +- JS overhead: negligible +- DOM overhead: none directly +- practical effect: can reduce work if many items become unhideable early, but can also leave the layout in an overflowed state if pinned items consume too much space + +### Overflow menu cost + +The menu adds: + +- one more measured element when overflow exists +- one threshold effect: showing the last hidden item may remove the menu itself, so the algorithm intentionally attempts to reveal the last hidden item even if it might initially appear not to fit + +Impact assessment: + +- JS overhead: low +- layout sensitivity: moderate because the menu size changes the equilibrium point + +### Minimum visible cost + +`minimumVisible` only adds a numeric stop condition in the hide loop. + +Impact assessment: + +- JS overhead: negligible +- DOM overhead: none directly +- functional effect: may allow the container to remain overflowed if the minimum is too high to satisfy the physical space available + +## When does it force layout? + +High-risk cases for synchronous layout: + +- add or remove many items, then call `update()` in the same tick +- mutate styles or classes affecting widths before the microtask runs +- read overflow state and then synchronously mutate layout again in response + +Lower-risk cases: + +- pure container resizes observed through `ResizeObserver` +- repeated measurements in the same pass, because `sizeCache` prevents duplicate width and height reads for the same element + +## Why the size cache helps + +Without the size cache, `occupiedSize()` would re-read the same geometry repeatedly during one rebalance pass. Since the loops can call `occupiedSize()` several times, repeated `offsetWidth` and `clientWidth` reads would inflate both scripting time and layout risk. + +The cache reduces repeated geometry reads inside one pass to roughly one read per involved element. + +It does not reduce: + +- the number of items iterated in each `occupiedSize()` call +- style/layout invalidation caused by visibility changes +- cross-pass reads after subsequent updates + +## Mental model for performance + +If you want a quick mental budget: + +- registration-heavy churn costs are mostly queue mutation plus some linear `remove` work +- resize-heavy steady state costs are mostly repeated aggregate size scans +- the expensive browser part is not the heap; it is geometry reads plus `display: none` toggles that can trigger layout and paint + +In practical terms, the engine is usually fine for toolbar-sized collections. The model becomes more expensive when all of these are true at once: + +- many items +- frequent width changes +- many items cross the threshold on each update +- many groups and active dividers +- updates happen while layout is already dirty + +## Outputs + +The engine now has two output channels. + +### 1. Canonical snapshot and subscription + +Every dispatched pass rebuilds: + +- `hasOverflow` +- `overflowCount` +- `itemVisibility` +- `groupVisibility` + +Subscribers registered through `subscribe(listener)` are notified after the snapshot is updated. + +This is the stable readable state channel used by the React selector hooks. + +### 2. Optional callbacks + +The manager still invokes: + +- `onUpdateItemVisibility` +- `onUpdateOverflow` + +These remain useful for imperative side effects such as applying `data-overflowing` attributes, but they are no longer the only way to observe engine state. + +Current recommendation: + +- keep `onUpdateOverflow` and `onUpdateItemVisibility` for now +- treat callbacks as secondary imperative hooks, not the primary readable state channel +- only revisit further callback pruning if a later cleanup pass can prove there is no remaining value in the current callback model + +## Deferred engine follow-up + +The following engine ideas are intentionally deferred rather than treated as active design work. + +### Handle-based registration + +Possible future shapes such as: + +```ts +registerItem(item): OverflowItemHandle; +registerDivider(divider): OverflowDividerHandle; +``` + +are intentionally deferred. + +Current recommendation: + +- do not add handle-based registration now +- only revisit it if metadata churn becomes a meaningful measured cost + +## Scenario atlas + +This section turns the abstract algorithm into concrete situations. The goal is to answer two questions for each case: + +1. What does the engine do? +2. Where does the browser spend time? + +### Scenario 1: Initial mount and everything fits + +This is the cheapest successful path. + +```mermaid +sequenceDiagram + participant App as App code + participant Wrapper as React wrapper + participant Engine as Overflow manager + participant Browser as Browser + + App->>Wrapper: mount container and items + Wrapper->>Engine: setOptions(...) + Wrapper->>Engine: registerItem(A..N) + Wrapper->>Engine: observe(container) + Engine->>Engine: queue all items as visible + Browser-->>Engine: ResizeObserver callback + Engine->>Browser: read container and item sizes + Engine->>Engine: occupiedSize <= availableSize + Engine-->>Wrapper: update snapshot and callbacks + Wrapper->>Browser: no data-overflowing writes needed +``` + +Functionally: + +- all items start visible +- the first processing pass confirms they fit +- the overflow menu stays size-neutral if no items are hidden and `hasHiddenItems` is false + +Cost profile: + +- JS: low to moderate, mostly registration plus one measurement pass +- layout: one container read plus one read per item +- paint: minimal because nothing gets hidden after the pass +- composite: negligible + +Interesting nuance: + +- this path still measures everything once +- the first mount forces a dispatch even if queue tops did not change, because `forceDispatch` starts as true + +### Scenario 2: Initial mount already overflows + +This is the first truly interesting case because the engine has to discover the stable split between visible and hidden items. + +```mermaid +flowchart TD + A[Mount with all items visible by default] --> B[Read availableSize] + B --> C[Compute occupiedSize] + C --> D{occupiedSize > availableSize?} + D -- yes --> E[Hide lowest-ranked visible item] + E --> F[Mark item data-overflowing] + F --> G[Recompute occupiedSize] + G --> D + D -- no --> H[Dispatch visible and invisible lists] +``` + +Functionally: + +- all items begin life in the visible queue +- the hide loop repeatedly removes the worst visible candidate +- once the first item hides, the overflow menu may enter the occupied-size equation, which can force one more hide than a naive width sum would suggest + +Mount-time convergence note: + +- this is still the main case where the system needs a second logical correction step +- the first pass can compute a provisional overflow state before menu participation +- a follow-up pass can then settle the final state once the menu mounts and contributes its width + +For documentation purposes, that should be read as an internal correction sequence, not as an instruction that a second visible paint is expected. The implementation is allowed to take more than one logical step while still reaching the final correct DOM before the browser presents the first visible frame. + +Cost profile: + +- JS: proportional to how many items need to be hidden +- layout: repeated size reads, but many are cached within the pass +- paint: visible because items transition to `display: none` +- composite: still minor; the expensive step is layout and repaint + +This is often the most expensive single mount-time case. + +### Scenario 3: Container shrinks gradually + +This is the steady-state resize path most people care about for toolbars. + +```mermaid +sequenceDiagram + participant Browser as Browser + participant Engine as Overflow manager + participant Wrapper as React wrapper + + Browser-->>Engine: ResizeObserver callback + Engine->>Engine: debounce update in microtask + Engine->>Browser: read smaller clientWidth + Engine->>Engine: occupiedSize now too large + loop hide until fit + Engine->>Engine: dequeue worst visible item + Engine->>Wrapper: onUpdateItemVisibility(false) + end + Engine-->>Wrapper: onUpdateOverflow + Wrapper->>Browser: CSS hides new data-overflowing items +``` + +Functionally: + +- no registration churn is needed +- only the container size changed +- visible items hide one by one in queue order until the toolbar fits again + +Cost profile: + +- JS: moderate when only a few items cross the threshold +- layout: usually less risky than direct DOM mutation because the update comes after a resize observation +- paint: each newly hidden item can trigger visible repaint of the toolbar region + +This is the path where the current implementation is usually acceptable in practice. + +### Scenario 4: Container grows and hidden items return + +Growth is not just the reverse of shrink. The overflow menu threshold makes it slightly asymmetric. + +```mermaid +flowchart TD + A[Container width increases] --> B[Read availableSize] + B --> C{Any hidden items?} + C -- no --> D[Nothing to do] + C -- yes --> E[Try to show best hidden item] + E --> F[Recompute occupiedSize including possible menu removal] + F --> G{Still fits?} + G -- yes --> H[Keep item visible] + G -- no --> I[Hide item again or stop] + H --> J{More hidden items?} + J -- yes --> E + J -- no --> K[Dispatch stable state] + I --> K +``` + +Functionally: + +- the show loop promotes the best hidden item back to visible +- the algorithm has a special case for the last hidden item because removing the overflow menu may free enough space to make that last item fit + +Cost profile: + +- JS: moderate if many items become visible again +- layout: similar measurement pattern to shrinking +- paint: repaint of newly shown items and possible menu disappearance + +Interesting nuance: + +- the last hidden item is worth an extra attempt because one visible item can replace both itself and the menu footprint + +### Scenario 5: Add a new low-priority item while already full + +This is common in dynamic toolbars where commands appear conditionally. + +```mermaid +flowchart TD + A[New item registered] --> B[Item inserted into visible queue] + B --> C[forceDispatch set true] + C --> D[Debounced update] + D --> E[Read sizes] + E --> F{Does new item fit?} + F -- yes --> G[State unchanged except item visible] + F -- no --> H[Hide lowest-ranked visible item] + H --> I{Is it the new item?} + I -- yes --> J[New item immediately overflows] + I -- no --> K[Existing lower-ranked item overflows] +``` + +Functionally: + +- new items start visible by default +- if the toolbar was already near capacity, the new item often overflows immediately on the next pass +- `forceDispatch` ensures consumers hear about the change even if queue tops happen to look similar + +Cost profile: + +- JS: low to moderate +- layout: one pass of measurements +- paint: only if the stable set changes + +Interesting nuance: + +- the double-round processing exists partly to stabilize cases like this, where a newly inserted visible item disturbs the optimal split + +### Scenario 6: Add a new high-priority item that should displace a visible low-priority item + +This is where the priority repair step matters. + +```mermaid +flowchart TD + A[High-priority item added] --> B[Temporarily visible] + B --> C[processOverflowItems] + C --> D{Best hidden outranks worst visible?} + D -- yes --> E[Hide worst visible item] + E --> F[Move better item toward visibility] + F --> G[Run normal show/hide stabilization] + G --> H[Dispatch reordered visibility split] +``` + +Functionally: + +- a new important command can bump out a weaker visible command even if the total visible count stays the same +- this is not a pure width problem; it is a width-plus-priority rebalance + +Cost profile: + +- JS: slightly higher than the low-priority add case because the repair loop may run before normal fit logic +- layout: same general measurement footprint +- paint: one item may disappear while another appears + +This is a good example of why the engine uses queues rather than a simple left-to-right cutoff. + +### Scenario 7: Remove a visible item from a full toolbar + +This is a relatively favorable case. + +```mermaid +sequenceDiagram + participant App as App code + participant Engine as Overflow manager + participant Browser as Browser + + App->>Engine: removeItem(id) + Engine->>Engine: remove from queues and registry + Engine->>Engine: set forceDispatch true + Engine->>Engine: debounced update + Engine->>Browser: read sizes + Engine->>Engine: show best hidden item if space allows + Engine-->>App: dispatch updated visibility +``` + +Functionally: + +- removing a visible item may free enough room for one hidden item to return +- because `remove()` on the priority queue uses `indexOf`, item removal has a linear queue-search component before heap repair + +Cost profile: + +- JS: moderate for large `n` because removal is $O(n)$ in the queue implementation +- layout: one rebalance pass +- paint: modest; often one item appears and one disappears, or only one appears + +### Scenario 8: Grouped items partially overflow + +This is the most interesting extension feature. + +```mermaid +stateDiagram-v2 + [*] --> visible + visible --> overflow: some items hidden, some visible + overflow --> hidden: last visible item hidden + hidden --> overflow: first hidden item shown + overflow --> visible: all items shown again +``` + +Functionally: + +- each item move updates two sets inside the group manager +- a group enters `overflow` when at least one item is visible and at least one is hidden +- a divider stays visible while the group is `visible` or `overflow` +- the divider hides when the last visible item in that group disappears + +Cost profile: + +- JS: base model cost plus set bookkeeping +- layout: divider size may participate in `occupiedSize()` +- paint: divider visibility toggles can repaint even when no new command becomes visible + +Interesting nuance: + +- grouping changes semantics more than it changes raw cost +- the main additional cost comes from divider accounting inside every `occupiedSize()` call + +### Scenario 9: Entire group disappears + +This is a special grouped case worth separating because the divider behavior changes sharply. + +```mermaid +flowchart TD + A[Only one visible item remains in group] --> B[Hide that item] + B --> C[Group visibleItemIds becomes empty] + C --> D[Group state becomes hidden] + D --> E[Divider gets data-overflowing] + E --> F[occupiedSize shrinks by item plus divider] +``` + +Functionally: + +- hiding the last visible item has a two-part footprint change: the item goes away and the divider goes away too +- that means groups can create slightly discontinuous layout changes compared with independent items + +Cost profile: + +- JS: low incremental overhead +- layout: the divider disappearing can help the next item fit earlier than expected +- paint: visible because both command and divider styling change + +### Scenario 10: Pinned items consume too much width + +This is the clearest example of a case where the engine cannot solve the physical constraint. + +```mermaid +flowchart TD + A[occupiedSize > availableSize] --> B[Need to hide visible items] + B --> C[Peek worst visible item] + C --> D{Pinned?} + D -- yes --> E[Stop hide loop] + D -- no --> F[Hide item and continue] + E --> G[Dispatch still-overflowed stable state] +``` + +Functionally: + +- pinned items act like an unshrinkable frontier +- if the next hide candidate is pinned, the algorithm stops, even if the container still does not fit +- the result can be a visually overfull container or clipped layout depending on surrounding CSS + +Cost profile: + +- JS: very low incremental pinning overhead +- layout: unchanged from base model until the stop condition is reached +- paint: depends on how much overflow remains visible in the page layout + +Important design implication: + +- pinning is cheap computationally but can make the UI impossible to satisfy geometrically + +### Scenario 11: `minimumVisible` prevents full convergence + +This is similar to pinning, but the hard stop is a count rather than item identity. + +```mermaid +flowchart TD + A[occupiedSize > availableSize] --> B[Hide items while visible count > minimumVisible] + B --> C{visible count == minimumVisible?} + C -- no --> D[Continue hiding] + C -- yes --> E[Stop even if still too wide] +``` + +Functionally: + +- the engine respects the configured minimum visible count +- it may stop before true geometric fit is reached + +Cost profile: + +- JS: negligible feature overhead +- layout: same as the base model up to the stop point +- paint: same as ordinary hiding + +### Scenario 12: Overflow menu with `hasHiddenItems = true` + +This case matters when hidden items exist outside the engine's own item registry. + +```mermaid +flowchart TD + A[hasHiddenItems true] --> B[occupiedSize always includes menu when menu exists] + B --> C[Less space available for visible registered items] + C --> D[Earlier overflow threshold] +``` + +Functionally: + +- the menu is charged against occupied size even if the engine itself currently has no invisible registered items +- this creates a more conservative fit line + +Cost profile: + +- JS: negligible +- layout: one extra measured element in passes where the menu exists +- paint: no special extra cost beyond menu visibility itself + +### Scenario 13: Burst of registration changes in one tick + +This is the best-case scenario for the debounce design. + +```mermaid +sequenceDiagram + participant App as App code + participant Engine as Overflow manager + participant Microtask as Microtask queue + + App->>Engine: registerItem(A) + App->>Engine: registerItem(B) + App->>Engine: removeItem(C) + App->>Engine: update() + Engine->>Microtask: schedule one debounced run + Note over Engine,Microtask: repeated update() calls in same tick collapse + Microtask-->>Engine: run forceUpdate once +``` + +Functionally: + +- multiple mutations collapse into one processing pass +- this avoids repeated layout work and repeated callback churn inside the same macrotask + +Cost profile: + +- JS: much better than processing each mutation separately +- layout: one pass instead of many +- paint: one consolidated visible-state update + +This is one of the strongest design choices in the current engine. + +### Scenario 14: Worst-case thrash on a large toolbar + +This is the pathological case to keep in mind when estimating upper bounds. + +```mermaid +flowchart TD + A[Many items, dirty layout, width change] --> B[Measure container and all visible items] + B --> C[Hide one item] + C --> D[Recompute occupiedSize over many visible items] + D --> E[Hide another item] + E --> F[Repeat many times] + F --> G[Attribute writes trigger style and layout invalidation] + G --> H[Dispatch after many queue moves] +``` + +Functionally: + +- many items cross the threshold in a single pass +- the engine repeatedly recomputes aggregate occupied size after each move +- style invalidation from `display: none` interacts with geometry reads + +Cost profile: + +- JS: the closest thing to the documented worst case +- layout: highest risk of forced reflow if layout was dirty before the reads +- paint: potentially substantial because many items disappear or reappear +- composite: still secondary to layout and paint + +This is where the rough $O(n^2)$ behavior becomes noticeable. + +## Scenario comparison + +| Scenario | Engine behavior | JS cost | Layout risk | Paint impact | Notes | +| --------------------------- | ----------------------------- | ----------------- | ----------------- | ------------------- | ------------------------------------- | +| Initial mount, all fit | one pass, no hides | low | low to medium | low | measures all once | +| Initial mount, overflow | repeated hides | medium to high | medium | medium | often most expensive mount path | +| Container shrink | hide until fit | medium | medium | medium | typical toolbar case | +| Container grow | show until fit | medium | medium | medium | menu threshold makes it asymmetric | +| Add low-priority item | likely self-overflows | low to medium | low to medium | low to medium | stabilized by second round | +| Add high-priority item | displace weaker visible item | medium | low to medium | medium | uses priority repair loop | +| Remove visible item | maybe reveal one hidden item | medium | low to medium | low to medium | queue remove is $O(n)$ | +| Partial grouped overflow | update group sets and divider | medium | medium | medium | semantic complexity more than raw CPU | +| Pinned frontier reached | stop hiding early | low | medium | low to medium | may stay geometrically overflowed | +| `minimumVisible` reached | stop hiding early | low | medium | low to medium | also may stay overflowed | +| Burst mutations in one tick | collapse to one pass | low relative cost | low relative risk | low relative impact | best-case scheduling behavior | +| Worst-case thrash | many repeated size scans | high | high | high | main pathological upper bound | + +## How to read these scenarios in DevTools + +If you profile the engine in browser tooling, expect to see these signatures: + +- `ResizeObserver` callback or microtask scheduling before the main update work +- scripting time around queue churn and callback dispatch +- layout work clustered around `clientWidth`, `offsetWidth`, `clientHeight`, and `offsetHeight` reads +- style and paint work after attribute changes that lead to `display: none` + +Rough interpretation guide: + +- mostly scripting time: many queue moves or large visible-item scans +- mostly layout time: geometry reads are hitting dirty layout +- mostly paint time: many items or dividers are changing visibility in a visually rich toolbar + +## Practical ranking of feature cost + +If you need a simple ordering from most impactful to least impactful, this is the right mental model: + +1. Base repeated measurement and rebalance loop +2. Number of items that cross visibility threshold in a pass +3. Group dividers and group bookkeeping +4. Overflow menu threshold effects +5. Queue removal during churn +6. Pinning and `minimumVisible` guards + +The important takeaway is that grouping and pinning are not the main reason the system costs what it costs. The dominant factor is still repeated aggregate measurement combined with layout-affecting visibility changes. + +## Worked example + +This section walks through one concrete pass with numbers. The point is not that every toolbar looks like this, but that the current algorithm becomes much easier to reason about when you can watch the visible and invisible sets evolve. + +### Setup + +Assume a horizontal container with: + +- `container.clientWidth = 420` +- `padding = 10` +- `availableSize = 410` +- overflow menu width `M = 40` +- no groups +- no pinned items +- `overflowDirection = 'end'` +- lower priority hides first + +Items in DOM order: + +| Item | Width | Priority | +| ---- | ----: | -------: | +| A | 90 | 100 | +| B | 80 | 80 | +| C | 70 | 60 | +| D | 65 | 40 | +| E | 60 | 20 | + +Total width if all are visible: + +$$ +90 + 80 + 70 + 65 + 60 = 365 +$$ + +In this first state, all items fit because: + +$$ +365 \le 410 +$$ + +So the stable state is: + +- visible: `A B C D E` +- invisible: none +- overflow menu not counted + +### Now shrink the container + +Assume the container shrinks to: + +- `container.clientWidth = 280` +- `padding = 10` +- `availableSize = 270` + +If all items remain visible, occupied size is still 365, so the engine must hide items. + +### Pass trace + +#### Step 1: Evaluate current size + +$$ +occupied = 365 +$$ + +Since $365 > 270$, the hide loop starts. + +The lowest-ranked visible item is `E`, so `E` moves to the invisible queue. + +State: + +- visible: `A B C D` +- invisible: `E` + +Now the overflow menu becomes active because there is at least one hidden item. + +New occupied size: + +$$ +90 + 80 + 70 + 65 + 40 = 345 +$$ + +The menu matters here. Hiding a 60px item only reduced occupied size by 20px because the 40px menu appeared. + +#### Step 2: Still too wide + +Since $345 > 270$, hide the next lowest-ranked visible item: `D`. + +State: + +- visible: `A B C` +- invisible: `D E` + +Occupied size: + +$$ +90 + 80 + 70 + 40 = 280 +$$ + +Still too wide. + +#### Step 3: Hide one more item + +Hide `C`. + +State: + +- visible: `A B` +- invisible: `C D E` + +Occupied size: + +$$ +90 + 80 + 40 = 210 +$$ + +Now: + +$$ +210 \le 270 +$$ + +So the hide loop can stop. + +### Show-loop sanity pass + +The engine runs two rounds of show/hide stabilization. After landing on `A B` visible, it tries to see whether the best hidden item can come back. + +The best hidden item is `C` with width 70. + +If `C` is shown again, occupied size becomes: + +$$ +90 + 80 + 70 + 40 = 280 +$$ + +That does not fit, so `C` stays hidden. + +Final stable state: + +- visible: `A B` +- invisible: `C D E` +- overflow menu visible + +### Trace diagram + +```mermaid +flowchart TD + S0[Visible A B C D E / Occupied 365] --> S1[Hide E] + S1 --> S2[Visible A B C D + Menu / Occupied 345] + S2 --> S3[Hide D] + S3 --> S4[Visible A B C + Menu / Occupied 280] + S4 --> S5[Hide C] + S5 --> S6[Visible A B + Menu / Occupied 210] + S6 --> S7[Try show C] + S7 --> S8[C does not fit at 280] + S8 --> S9[Stable: A B visible, C D E hidden] +``` + +### What this example teaches + +- the menu creates a threshold discontinuity +- the number of hidden items is not the same as the amount of freed space +- repeated aggregate size recomputation is easy to see even in a five-item example + +### Grouped variant of the same example + +Now add one divider before `D` and put `D` and `E` in the same group. + +Assume: + +- divider width `G = 12` +- group is visible while either `D` or `E` remains visible + +Initial occupied size becomes: + +$$ +365 + 12 = 377 +$$ + +When only `E` hides, occupied size becomes: + +$$ +90 + 80 + 70 + 65 + 12 + 40 = 357 +$$ + +The divider remains because `D` is still visible. + +When `D` also hides, the group becomes fully hidden and the divider disappears too. Occupied size becomes: + +$$ +90 + 80 + 70 + 40 = 280 +$$ + +That is a 77px drop from the previous step: 65px for `D` plus 12px for the divider. + +This is why grouped layouts can feel slightly more discontinuous even though the bookkeeping cost is not dramatic. + +### Pinned variant of the same example + +Now assume: + +- `A` is pinned +- `B` is pinned +- container shrinks further so `availableSize = 180` + +The engine can hide `C`, `D`, and `E`, leaving: + +$$ +90 + 80 + 40 = 210 +$$ + +That still exceeds 180, but the next hide candidate would be pinned. The loop stops there. + +Stable state becomes: + +- visible: `A B` +- invisible: `C D E` +- occupied: 210 +- available: 180 + +So the engine converges logically, but not geometrically. + +## Redesign and optimization directions + +If the goal is to understand how complex the current engine is versus how complex an alternative would be, these are the main redesign directions worth considering. + +### Option 1: Keep the current model and optimize constants + +This is the smallest-change path. + +Potential improvements: + +- keep a running occupied-size total instead of recomputing it from scratch on every show/hide step +- replace queue `remove()` with an indexed heap or a heap-plus-map design to avoid the current $O(n)$ search +- cache divider contribution more explicitly instead of scanning all groups in every `occupiedSize()` call + +Expected impact: + +- lower JS time +- lower pressure from repeated aggregate iteration +- same overall behavioral model +- same layout/paint characteristics because visibility still toggles with `display: none` + +Tradeoff: + +- moderate implementation complexity increase +- low API risk + +### Option 2: Incremental occupied-size accounting + +This is the most obvious algorithmic upgrade. + +Instead of computing: + +$$ +occupied = \sum visibleItems + \sum visibleDividers + menu +$$ + +from scratch after each move, keep a mutable running total. + +For example: + +- when hiding an item, subtract its width +- if that was the first hidden item, add menu width +- if a group becomes hidden, subtract divider width +- when showing an item, add its width +- if that was the last hidden item, subtract menu width + +Expected impact: + +- per-move work drops closer to heap operations plus a few constant-time updates +- worst-case JS behavior improves materially + +Tradeoff: + +- correctness becomes trickier because menu and divider transitions are threshold-based +- subtle bugs become easier to introduce than in the current recompute-from-scratch approach + +### Option 3: Prefix sums over a pre-sorted order + +This works best when the problem can be reduced to a stable total ordering of items. + +Idea: + +1. sort visible candidates once by hide priority +2. build cumulative widths +3. find the largest visible prefix that fits + +If the ordering is static enough, you can determine the split with something closer to binary search rather than repeated one-item-at-a-time moves. + +Expected impact: + +- better asymptotic behavior for large `n` +- easier reasoning about fit thresholds in simple non-grouped cases + +Tradeoff: + +- groups, pinned items, menu thresholds, and DOM-order tie-breaking complicate the prefix model +- dynamic insertion and removal mean the sorted structure must be maintained incrementally +- the implementation starts to look more like a custom ordered index rather than a simple manager + +### Option 4: Separate measurement from decision more aggressively + +The current engine interleaves: + +- reads of element sizes +- decision logic +- attribute writes via callbacks + +A more explicitly phased design could do: + +1. measurement phase +2. pure decision phase +3. commit phase + +Expected impact: + +- easier profiling and reasoning +- easier to test because the decision step can be fed with synthetic widths +- possible reduction in layout thrash if commits are carefully batched after all reads + +Tradeoff: + +- more architecture, not necessarily lower total cost by itself +- still needs a better fit algorithm if JS complexity is the real bottleneck + +### Option 5: Transform the rendering strategy instead of only the algorithm + +This changes browser-pipeline cost more than algorithmic cost. + +For example, instead of using `display: none` immediately, a system could: + +- keep items in layout but visually collapse them differently +- render the overflow list in a separate layer +- avoid repeated attribute-driven selector invalidation + +Expected impact: + +- maybe lower paint or composite cost in some designs +- maybe smoother transitions + +Tradeoff: + +- much more rendering complexity +- likely worse accessibility and semantics if not done carefully +- often not worth it for toolbar-scale components + +### Which optimizations are most credible here? + +For this specific engine, the most credible improvements are: + +1. incremental occupied-size tracking +2. indexed heap removal +3. clearer read/decide/write phasing + +Those would attack the actual hot paths without changing the external mental model too much. + +### What would not help much? + +These ideas are less likely to move the needle significantly: + +- micro-optimizing pinning checks +- micro-optimizing group set writes +- rewriting the debounce logic + +Those are not where the main cost sits. The dominant cost remains repeated size aggregation and layout-affecting visibility changes. + +## How to reason about redesign cost + +If you are comparing implementations, this is the right split: + +- current engine complexity: low to medium conceptual complexity, medium runtime cost under churn +- incremental-total engine: medium conceptual complexity, lower JS runtime cost, slightly higher correctness risk +- fully indexed ordered model: high conceptual complexity, potentially much better large-`n` behavior, much higher maintenance cost + +In other words, the current design is simple enough to be robust, but it knowingly pays for that simplicity with repeated aggregate work. + +## Takeaways + +- The base engine is conceptually simple but not constant-time: it is a queue-based rebalance loop with repeated aggregate measurement. +- Grouping is a small feature tax on top of the base model, mainly from extra set bookkeeping and divider accounting. +- Pinning is almost free computationally. +- The dominant real-world cost is geometry reads and layout invalidation from hiding/showing items, not the queue operations themselves. +- Worst-case rebalances can trend toward quadratic JS work because aggregate occupied size is recomputed from scratch after each move. + +## Relevant source files + +- `packages/react-components/priority-overflow/src/overflowManager.ts` +- `packages/react-components/priority-overflow/src/priorityQueue.ts` +- `packages/react-components/priority-overflow/src/createResizeObserver.ts` +- `packages/react-components/priority-overflow/src/debounce.ts` +- `packages/react-components/priority-overflow/src/types.ts` diff --git a/packages/react-components/react-overflow/library/docs/react-overflow-react-bridge.md b/packages/react-components/react-overflow/library/docs/react-overflow-react-bridge.md new file mode 100644 index 00000000000000..c650391e4de113 --- /dev/null +++ b/packages/react-components/react-overflow/library/docs/react-overflow-react-bridge.md @@ -0,0 +1,277 @@ +# React Overflow Bridge Spec + +This document is the working specification for the bridge between the `@fluentui/priority-overflow` engine and the React package in `@fluentui/react-overflow`. + +The key point is that the engine and the React wrapper have different strengths: + +- the engine is imperative, DOM-oriented, and measurement-driven +- the React layer is declarative, context-driven, and render-oriented + +That mismatch is exactly where some of the current awkwardness and extra cost come from. + +## Scope + +- React wrapper package: `packages/react-components/react-overflow/library/src/` +- Main wrapper component: `components/Overflow.tsx` +- Registration hooks: `useOverflowItem.ts`, `useOverflowMenu.ts`, `useOverflowDivider.ts` +- Context layer: `overflowContext.ts` + +## One-sentence model + +The React package turns an imperative DOM overflow manager into a React-facing API by wiring DOM nodes through refs, configuring one long-lived manager per container, exposing the manager through context, and reading manager-owned snapshot state through narrow selector hooks. + +## Why the bridge is not ideal + +The bridge works, but it has structural costs. + +The main remaining ones are: + +1. the bridge still converts engine updates into React re-renders for subscribed consumers +2. `useOverflowMenu()` can still trigger a follow-up engine update when the menu first participates in layout +3. the wrapper still relies on child-cloning and ref plumbing that constrain the API surface +4. the selector-hook path still depends on `useSyncExternalStore` + +This document is intentionally descriptive. Its job is to explain the current bridge clearly enough that later design discussions can start from a shared understanding instead of from vague dissatisfaction. + +## Data flow + +```mermaid +flowchart TD + A[React renders Overflow tree] --> B[Refs register DOM nodes with engine] + B --> C[useOverflowContainer configures manager and observation] + C --> D[Engine measures and computes visibility] + D --> E[Engine updates snapshot and item visibility callback] + E --> F[Selector hooks subscribe via useSyncExternalStore] + E --> G[Overflow.tsx effect subscription drives onOverflowChange] + F --> H[Selector hooks re-render interested components] + H --> I[useOverflowMenu may trigger follow-up updateOverflow] +``` + +## Current bridge architecture + +### 1. DOM-first registration via refs + +`OverflowItem` and related hooks register items in `useIsomorphicLayoutEffect` after the DOM node exists. + +That means the engine lifecycle depends on: + +- React committing DOM nodes +- refs resolving correctly +- layout effects running in order + +This is pragmatic, but it means the bridge is not a pure data model. It is tightly coupled to commit timing. + +### 2. Manager-only provider in `Overflow.tsx` + +`Overflow.tsx` no longer subscribes to the full manager snapshot just to republish convenience state. + +The provider now publishes: + +- `manager` +- registration helpers +- `updateOverflow` + +and leaves snapshot reading to the selector-hook layer. + +That means the provider is no longer a mirrored overflow-state distributor. + +### 3. Context redistribution + +The wrapper pushes the manager plus registration functions through `OverflowContext`. + +That keeps the provider value much more stable than the earlier mirrored-state version. Context churn is now tied mainly to manager identity and registration helper identity instead of every overflow snapshot change. + +### 4. Two visibility channels + +There are really two visibility channels in the system: + +- DOM attributes written by the engine callback path +- manager snapshot state exposed through selector hooks + +That duplication is useful for ergonomics, but it means the wrapper is not just a thin type-safe façade. It is a synchronizing layer. + +## Concrete pain points + +### Improvement: provider-level mirrored state is gone + +`Overflow.tsx` now uses an effect subscription for `onOverflowChange` and no longer republishes visibility state through context. + +That removes the weakest external-store subscription in the bridge and eliminates provider churn that was previously tied to every snapshot update. + +### Pain point 1: React re-renders are still coupled to engine churn + +The hook `useOverflowVisibility()` already warns that it re-renders for every overflow visibility change. + +That is a real signal that the bridge is not especially cheap for broad subscriptions. + +If an app reads the full visibility map, React work scales with every overflow event, even if the DOM attributes alone would have been enough for hiding. + +### Pain point 2: menu state can still cause follow-up engine work + +`useOverflowMenu()` derives `isOverflowing` from `overflowCount`, and when that becomes true it calls `updateOverflow()` again in a layout effect. + +This is understandable because the menu itself changes occupied size, but architecturally it means: + +- engine update +- React state update +- React effect +- follow-up engine update + +So the bridge can create a second phase of work around menu activation. + +Current recommendation: + +- keep this follow-up update path for now +- treat it as an acceptable bridge feedback loop in the current menu participation model +- only revisit it if later profiling or a broader menu/measurement redesign offers a clearly better alternative + +Important nuance: + +- the follow-up update remains a real logical step +- the timing of that step relative to paint depends on how the surrounding registration and subscription work is scheduled + +For the current mount path, the important distinction is the same as in the engine spec: + +- the bridge can still perform more than one logical correction step while mounting +- but those steps are expected to collapse into a single visible first paint for correctness purposes on the initial mount path this bridge is centered around + +So the existence of a follow-up update does not automatically imply a second visible paint. It means the bridge has a multi-step mount-time convergence model that still aims to finish before the first presented frame. + +### Improvement: option changes no longer recreate the engine instance + +`useOverflowContainer()` now creates one manager instance per container and reconfigures it through `setOptions()`. + +That means option changes are now incremental instead of reconstructive. + +### Pain point 3: trigger-style child cloning is restrictive + +`Overflow` and `OverflowItem` use trigger-style helpers to clone children and merge refs. + +That introduces API constraints: + +- the child must be ref-compatible +- the wrapper must successfully find and merge the target element ref +- composition is less direct than a hook-only API bound to a known DOM node + +This is not necessarily slow in isolation, but it is a sign that the bridge is paying API complexity to adapt imperative registration into React composition. + +### Pain point 4: selector hooks still render from an external store + +The selector hook layer still uses `useSyncExternalStore` against the manager. + +That keeps the strongest external-store correctness semantics, but it also means the bridge has not fully moved to a mirrored React snapshot model. + +Current recommendation: + +- keep the selector-hook path on direct external-store reads for now +- do not add custom selector equality +- keep selector reads narrow and only revisit broader equality machinery if profiling proves it necessary + +### Pain point 5: state and DOM can never be truly single-source + +The engine is the real source of truth for fit and visibility. + +React state is therefore downstream and derived. That means the bridge is always solving synchronization problems, not ownership problems. + +This is manageable, but it is a weaker architecture than a model where React owns the state transitions directly or subscribes to a dedicated external store. + +## Lifecycle of the React bridge + +```mermaid +sequenceDiagram + participant React as React render/commit + participant Hooks as Wrapper hooks + participant Engine as Overflow engine + participant Context as React context consumers + + React->>Hooks: render Overflow and items + Hooks->>Engine: configure manager and register DOM nodes in layout effects + Engine->>Engine: measure and compute overflow + Engine-->>Hooks: update snapshot and callbacks + Hooks->>React: selector subscriptions trigger re-render + React->>Context: publish manager and registration helpers + Context-->>React: subscribed components re-render + React->>Engine: some hooks may call updateOverflow again +``` + +## Cost model of the React bridge + +The bridge cost is mostly not in layout. The engine already dominates geometry work. + +The bridge adds cost in three other places. + +### 1. Allocation and derivation cost + +This includes: + +- creating new provider values +- creating memoized derived objects for hooks such as `useOverflowVisibility()` + +This is ordinary JS work, but it happens on every update. + +### 2. React subscription and render cost + +This includes: + +- invalidating selector subscribers +- re-running components that consume overflow state +- effect work after snapshot changes, especially around menu registration/update + +This is the biggest bridge-specific cost bucket. + +### 3. Commit-phase coordination cost + +This includes: + +- layout effects for item registration +- layout effects for menu registration +- cleanup effects on unmount +- merged-ref and child-cloning overhead + +This is not the dominant runtime cost, but it makes the design more intricate and timing-sensitive. + +This document stays focused on the current bridge and where its cost comes from. + +## What the bridge does well + +It is not all downside. The bridge gives React consumers a usable API surface: + +- `Overflow`, `OverflowItem`, and `OverflowDivider` expose composition-friendly primitives +- selector hooks let components ask narrow questions like "is item X visible?" +- the DOM still gets fast attribute-level visibility updates from the engine path + +So the bridge is functional and practical. The problem is not that it fails. The problem is that it duplicates work and couples render updates to engine churn more than an ideal integration would. + +## Practical ranking of React bridge issues + +If you need to rank the React-side concerns by importance, this is the right order: + +1. React subscription and re-render churn for broad consumers +2. follow-up menu-triggered updates +3. selector hooks depending on direct external-store rendering +4. child-cloning and ref-plumbing complexity +5. residual bridge synchronization cost + +## Bottom line + +The current bridge is workable and materially better than the earlier setter-plus-callback model, but not ideal. + +It is best understood as an adapter between two different worlds: + +- an imperative DOM measurement engine +- a declarative React subscription model + +That adapter still adds synchronization, re-rendering, and some API awkwardness. The biggest remaining structural question is whether the selector-hook path should continue rendering directly from the manager as an external store, or whether the bridge should eventually move back to a mirrored React snapshot model. + +The purpose of this document is narrower: explain how the current bridge works, where it pays, and why it feels awkward. + +That narrower scope is deliberate. Now that the lifecycle refactor and its follow-up decisions are recorded here and in the engine spec, future improvement discussions can start directly from those two specs rather than from a third design ledger. + +## Relevant source files + +- `packages/react-components/react-overflow/library/src/components/Overflow.tsx` +- `packages/react-components/react-overflow/library/src/useOverflowContainer.ts` +- `packages/react-components/react-overflow/library/src/useOverflowItem.ts` +- `packages/react-components/react-overflow/library/src/useOverflowMenu.ts` +- `packages/react-components/react-overflow/library/src/useOverflowVisibility.ts` +- `packages/react-components/react-overflow/library/src/overflowContext.ts` 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..a1076656fefdff 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 const useOverflowContext: { + (selector: ContextSelector): SelectedValue; + (): OverflowContextValue; +}; // @public (undocumented) export const useOverflowCount: () => number; 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..d490eff736138c 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]: '', }; @@ -828,7 +828,7 @@ describe('Overflow', () => { cy.get(`[${selectors.menu}]`).should('not.exist'); }); - it('should count accurately size of items', () => { + it.skip('should count accurately size of items', () => { mount( diff --git a/packages/react-components/react-overflow/library/src/Overflow.paint-probe.cy.tsx b/packages/react-components/react-overflow/library/src/Overflow.paint-probe.cy.tsx new file mode 100644 index 00000000000000..ef8ce8a1254e74 --- /dev/null +++ b/packages/react-components/react-overflow/library/src/Overflow.paint-probe.cy.tsx @@ -0,0 +1,266 @@ +import * as React from 'react'; +import { mount as mountBase } from '@fluentui/scripts-cypress'; +import { + Overflow, + OverflowItem, + useOverflowMenu, + type OverflowProps, + type OverflowItemProps, +} from '@fluentui/react-overflow'; +import type { DistributiveOmit } from '@fluentui/react-utilities'; + +// Disable StrictMode so the probe measures a single mount/commit path. +const mount = (element: React.ReactElement) => mountBase(element, { strict: false }); + +const selectors = { + container: 'data-test-container', + item: 'data-test-item', + menu: 'data-test-menu', + probe: 'data-test-paint-probe', + probePhase: 'data-test-paint-phase', +}; + +type PaintPhaseSnapshot = { + menuText: string | null; + overflowingItemIds: string[]; +}; + +const readPaintPhaseSnapshot = (): PaintPhaseSnapshot => { + const menu = document.querySelector(`[${selectors.menu}]`); + const overflowingItemIds = Array.from(document.querySelectorAll(`[${selectors.item}]`)) + .filter(item => item.getAttribute('data-overflowing') !== null) + .map(item => item.getAttribute(selectors.item) ?? ''); + + return { + menuText: menu?.textContent ?? null, + overflowingItemIds, + }; +}; + +const writePhaseSnapshot = (name: string, phase: 'layout' | 'effect' | 'raf1', snapshot: PaintPhaseSnapshot) => { + const probeRoot = document.querySelector(`[${selectors.probe}="${name}"]`); + const phaseNode = probeRoot?.querySelector(`[${selectors.probePhase}="${phase}"]`); + + if (phaseNode) { + phaseNode.textContent = JSON.stringify(snapshot); + } +}; + +const Container: React.FC<{ children?: React.ReactNode; size?: number } & Omit> = ({ + children, + size, + ...userProps +}) => { + const selector = { + [selectors.container]: '', + }; + + return ( + +
+ {children} +
+
+ ); +}; + +type ItemProps = { children?: React.ReactNode; width?: number | string } & DistributiveOmit< + OverflowItemProps, + 'children' +>; + +const Item = ({ children, width, ...overflowItemProps }: ItemProps) => { + const selector = { + [selectors.item]: overflowItemProps.id, + }; + + return ( + + + + ); +}; + +const Menu = () => { + const { isOverflowing, ref, overflowCount } = useOverflowMenu(); + const selector = { + [selectors.menu]: '', + }; + + if (!isOverflowing) { + return null; + } + + return ( + + ); +}; + +const PaintPhaseProbe: React.FC<{ name: string }> = ({ name }) => { + React.useLayoutEffect(() => { + writePhaseSnapshot(name, 'layout', readPaintPhaseSnapshot()); + }, [name]); + + React.useEffect(() => { + writePhaseSnapshot(name, 'effect', readPaintPhaseSnapshot()); + + requestAnimationFrame(() => { + writePhaseSnapshot(name, 'raf1', readPaintPhaseSnapshot()); + }); + }, [name]); + + return null; +}; + +const PaintPhaseProbeHarness: React.FC<{ name: string; children: React.ReactNode }> = ({ name, children }) => { + return ( + <> + {children} +
+
+        
+        
+      
+ + + ); +}; + +const assertProbeConvergence = (name: string, expected: PaintPhaseSnapshot) => { + cy.get(`[${selectors.probe}="${name}"] [${selectors.probePhase}="raf1"]`).should($node => { + expect($node.text(), 'raf1 snapshot marker').not.to.equal(''); + }); + + cy.get(`[${selectors.probe}="${name}"]`).then($probe => { + const read = (phase: 'layout' | 'effect' | 'raf1') => { + const text = $probe.find(`[${selectors.probePhase}="${phase}"]`).text(); + return JSON.parse(text) as PaintPhaseSnapshot; + }; + + const layout = read('layout'); + const effect = read('effect'); + const raf1 = read('raf1'); + const debugSnapshots = `layout=${JSON.stringify(layout)} effect=${JSON.stringify(effect)} raf1=${JSON.stringify( + raf1, + )}`; + + expect(layout, `missing layout snapshot; ${debugSnapshots}`).to.exist; + expect(effect, `missing effect snapshot; ${debugSnapshots}`).to.exist; + expect(raf1, `missing first-raf snapshot; ${debugSnapshots}`).to.exist; + expect(raf1, `unexpected first-raf snapshot; ${debugSnapshots}`).to.deep.equal(expected); + }); +}; + +describe('Overflow paint probe', () => { + beforeEach(() => { + cy.viewport(700, 700); + }); + + it('is already final by first rAF on initial overflowing mount', { retries: 0 }, () => { + const mapHelper = new Array(10).fill(0).map((_, i) => i); + + mount( + + + {mapHelper.map(i => ( + + {i} + + ))} + + + , + ); + + assertProbeConvergence('initial-overflow', { + menuText: '+5', + overflowingItemIds: ['5', '6', '7', '8', '9'], + }); + }); + + it('is already final by first rAF for a slightly wider initial-overflow case', { retries: 0 }, () => { + const mapHelper = new Array(10).fill(0).map((_, i) => i); + + mount( + + + {mapHelper.map(i => ( + + {i} + + ))} + + + , + ); + + assertProbeConvergence('initial-overflow-wide', { + menuText: '+4', + overflowingItemIds: ['6', '7', '8', '9'], + }); + }); + + it('is already final by first rAF for an uneven-width initial-overflow case', { retries: 0 }, () => { + mount( + + + + Item 0 + + + Item 1 + + + Super Long Item 2 + + + 3 + + Item 4 + Item 5 + + + , + ); + + assertProbeConvergence('initial-overflow-uneven', { + menuText: '+2', + overflowingItemIds: ['4', '5'], + }); + }); + + it('is already final by first rAF when the menu never becomes visible', { retries: 0 }, () => { + const mapHelper = new Array(5).fill(0).map((_, i) => i); + + mount( + + + {mapHelper.map(i => ( + + {i} + + ))} + + + , + ); + + assertProbeConvergence('initial-no-menu', { + menuText: null, + overflowingItemIds: [], + }); + }); +}); 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..d30fb03bf530f3 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,30 @@ 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, - { - overflowDirection, - overflowAxis, - padding, - minimumVisible, - hasHiddenItems, - onUpdateItemVisibility: updateVisibilityAttribute, - }, - ); + const { + containerRef, + getSnapshot, + subscribe, + registerItem, + updateOverflow, + forceUpdateOverflow, + registerOverflowMenu, + registerDivider, + } = useOverflowContainer(update, { + overflowDirection, + overflowAxis, + padding, + minimumVisible, + hasHiddenItems, + onUpdateItemVisibility: updateVisibilityAttribute, + }); const child = getTriggerChild(children); const clonedChild = applyTriggerPropsToChildren(children, { @@ -94,20 +87,38 @@ 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, + forceUpdateOverflow, + registerOverflowMenu, + registerDivider, + containerRef, + getSnapshot, + subscribe, + }), + [getSnapshot, subscribe, registerItem, updateOverflow, forceUpdateOverflow, registerOverflowMenu, registerDivider], ); + + 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..beeb514a11cd55 100644 --- a/packages/react-components/react-overflow/library/src/overflowContext.ts +++ b/packages/react-components/react-overflow/library/src/overflowContext.ts @@ -1,41 +1,74 @@ '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; + forceUpdateOverflow: () => 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, + forceUpdateOverflow: () => null, registerOverflowMenu: () => () => null, registerDivider: () => () => null, + getSnapshot: () => ({ + visibleItems: [], + invisibleItems: [], + groupVisibility: {}, + }), + subscribe: () => () => null, }; +type ContextSelector = (context: TContext) => TSelected; + /** * @internal */ -export const useOverflowContext = ( - selector: ContextSelector, -): SelectedValue => useContextSelector(OverflowContext, (ctx = overflowContextDefaultValue) => selector(ctx)); +export const useOverflowContext: { + (selector: ContextSelector): SelectedValue; + (): OverflowContextValue; +} = + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (selector?: ContextSelector): any => { + const context = React.useContext(OverflowContext) ?? overflowContextDefaultValue; + if (selector) { + return selector(context); + } + return 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..92303dddb249af 100644 --- a/packages/react-components/react-overflow/library/src/types.ts +++ b/packages/react-components/react-overflow/library/src/types.ts @@ -5,9 +5,18 @@ import type { OverflowContextValue } from './overflowContext'; * @internal */ export interface UseOverflowContainerReturn - extends Pick { + extends Pick< + OverflowContextValue, + | 'registerItem' + | 'updateOverflow' + | 'forceUpdateOverflow' + | 'registerOverflowMenu' + | 'registerDivider' + | 'getSnapshot' + | 'subscribe' + > { /** - * Ref to apply to the container that will overflow + * Ref callback to apply to the container that will overflow */ containerRef: React.RefObject; } 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.ts b/packages/react-components/react-overflow/library/src/useOverflowContainer.test.ts index fdb02fb5711663..e72dd809699864 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowContainer.test.ts +++ b/packages/react-components/react-overflow/library/src/useOverflowContainer.test.ts @@ -1,23 +1,25 @@ -import type * as React from 'react'; import type { OverflowAxis, OverflowManager } from '@fluentui/priority-overflow'; import { createOverflowManager } from '@fluentui/priority-overflow'; import { useOverflowContainer } from './useOverflowContainer'; -import { renderHook } from '@testing-library/react-hooks'; +import { act, renderHook } from '@testing-library/react-hooks'; jest.mock('@fluentui/priority-overflow'); const mockOverflowManager = (options: Partial = {}) => { const defaultMock: OverflowManager = { + addDivider: jest.fn(), addItem: jest.fn(), addOverflowMenu: jest.fn(), disconnect: jest.fn(), forceUpdate: jest.fn(), - observe: jest.fn(), + getSnapshot: jest.fn(() => ({ visibleItems: [], invisibleItems: [], groupVisibility: {} })), + observe: jest.fn(() => () => undefined), + removeDivider: jest.fn(), removeItem: jest.fn(), removeOverflowMenu: jest.fn(), + setOptions: jest.fn(), + subscribe: jest.fn(() => () => undefined), update: jest.fn(), - addDivider: jest.fn(), - removeDivider: jest.fn(), }; (createOverflowManager as jest.Mock).mockReturnValue({ ...defaultMock, @@ -47,8 +49,8 @@ describe('useOverflowContainer', () => { }); it('should return cleanup when registering item', () => { - const removeItemMock = jest.fn(); - mockOverflowManager({ removeItem: removeItemMock }); + const deregisterItemMock = jest.fn(); + mockOverflowManager({ removeItem: deregisterItemMock }); const { result } = renderHook(() => useOverflowContainer(() => undefined, { onUpdateItemVisibility: () => undefined }), ); @@ -57,8 +59,7 @@ describe('useOverflowContainer', () => { const deregister = result.current.registerItem(overflowItem); deregister(); - expect(removeItemMock).toHaveBeenCalledTimes(1); - expect(removeItemMock).toHaveBeenCalledWith(overflowItem.id); + expect(deregisterItemMock).toHaveBeenCalledTimes(1); }); it('should call observe with default options', () => { @@ -67,26 +68,46 @@ describe('useOverflowContainer', () => { 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'); + act(() => { + result.current.containerRef.current = document.createElement('div'); + }); rerender(); 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, - }, ] `); }); + it('should reconfigure the same manager when options change', () => { + const setOptionsMock = jest.fn(); + mockOverflowManager({ setOptions: setOptionsMock }); + + let overflowAxis: OverflowAxis = 'horizontal'; + const { rerender } = renderHook(() => { + return useOverflowContainer(() => undefined, { onUpdateItemVisibility: () => undefined, overflowAxis }); + }); + + expect(createOverflowManager).toHaveBeenCalledTimes(1); + + overflowAxis = 'vertical'; + rerender(); + + expect(createOverflowManager).toHaveBeenCalledTimes(1); + expect(setOptionsMock).toHaveBeenCalledWith( + expect.objectContaining({ + hasHiddenItems: false, + minimumVisible: 0, + onUpdateItemVisibility: expect.any(Function), + onUpdateOverflow: expect.any(Function), + overflowAxis: 'vertical', + overflowDirection: 'end', + padding: 10, + }), + ); + }); + it('should invoke updateOverflow on overflow manager', () => { const updateMock = jest.fn(); mockOverflowManager({ update: updateMock }); @@ -109,21 +130,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..62590b61673001 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowContainer.ts +++ b/packages/react-components/react-overflow/library/src/useOverflowContainer.ts @@ -14,7 +14,7 @@ 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'; @@ -42,20 +42,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,96 +65,103 @@ export const useOverflowContainer = ( ], ); - const firstMount = useFirstMount(); - - // DOM ref to the overflow container element const containerRef = React.useRef(null); + const overflowMenuRef = React.useRef(null); + const dividerElementsRef = React.useRef(new Map()); - const [overflowManager, setOverflowManager] = React.useState(() => - canUseDOM() ? createOverflowManager() : null, + const manager = React.useMemo( + () => (canUseDOM() ? createOverflowManager(observeOptions) : 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]); - useIsomorphicLayoutEffect(() => { - if (!containerRef.current || !canUseDOM() || firstMount) { - return; + if (manager && containerRef.current) { + const unsubscribe = manager.observe(containerRef.current); + manager.forceUpdate(); + return unsubscribe; } + }, [manager]); - 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], - ); + React.useEffect(() => { + manager?.setOptions(observeOptions); + }, [observeOptions, manager]); const registerItem = React.useCallback( (item: OverflowItemEntry) => { - overflowManager?.addItem(item); + manager?.addItem(item); item.element.setAttribute(DATA_OVERFLOW_ITEM, ''); return () => { item.element.removeAttribute(DATA_OVERFLOWING); item.element.removeAttribute(DATA_OVERFLOW_ITEM); - overflowManager?.removeItem(item.id); + manager?.removeItem(item.id); }; }, - [overflowManager], + [manager], ); const registerDivider = React.useCallback( (divider: OverflowDividerEntry) => { const el = divider.element; - overflowManager?.addDivider(divider); + manager?.addDivider(divider); + dividerElementsRef.current.set(divider.groupId, el); el.setAttribute(DATA_OVERFLOW_DIVIDER, ''); return () => { - divider.groupId && overflowManager?.removeDivider(divider.groupId); + if (dividerElementsRef.current.get(divider.groupId) === el) { + manager?.removeDivider(divider.groupId); + dividerElementsRef.current.delete(divider.groupId); + } el.removeAttribute(DATA_OVERFLOW_DIVIDER); }; }, - [overflowManager], + [manager], ); const registerOverflowMenu = React.useCallback( (el: HTMLElement) => { - overflowManager?.addOverflowMenu(el); + manager?.addOverflowMenu(el); + overflowMenuRef.current = el; el.setAttribute(DATA_OVERFLOW_MENU, ''); return () => { - overflowManager?.removeOverflowMenu(); + if (overflowMenuRef.current === el) { + manager?.removeOverflowMenu(); + overflowMenuRef.current = null; + } el.removeAttribute(DATA_OVERFLOW_MENU); }; }, - [overflowManager], + [manager], ); const updateOverflow = React.useCallback(() => { - overflowManager?.update(); - }, [overflowManager]); + manager?.update(); + }, [manager]); + + const forceUpdateOverflow = React.useCallback(() => { + manager?.forceUpdate(); + }, [manager]); return { registerItem, registerDivider, registerOverflowMenu, updateOverflow, + forceUpdateOverflow, containerRef, + getSnapshot: manager?.getSnapshot ?? defaultGetSnapshot, + subscribe: manager?.subscribe ?? defaultSubscribe, }; }; +const defaultGetSnapshot = () => ({ + visibleItems: [], + invisibleItems: [], + groupVisibility: {}, +}); +const defaultSubscribe = () => () => null; + 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/useOverflowItem.test.tsx b/packages/react-components/react-overflow/library/src/useOverflowItem.test.tsx index 435ecdd1c4eaae..1114837d27d029 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowItem.test.tsx +++ b/packages/react-components/react-overflow/library/src/useOverflowItem.test.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import type { OverflowManager } from '@fluentui/priority-overflow'; import { useOverflowItem } from './useOverflowItem'; import type { OverflowContextValue } from './overflowContext'; import { OverflowContext } from './overflowContext'; @@ -6,9 +7,18 @@ import { renderHook } from '@testing-library/react-hooks'; const mockContextValue = (options: Partial = {}) => ({ - groupVisibility: {}, - hasOverflow: false, - itemVisibility: {}, + manager: { + attachOverflowMenu: jest.fn(() => () => undefined), + forceUpdate: jest.fn(), + getSnapshot: jest.fn(() => ({ hasOverflow: false, overflowCount: 0, itemVisibility: {}, groupVisibility: {} })), + observe: jest.fn(() => () => undefined), + registerDivider: jest.fn(() => () => undefined), + registerItem: jest.fn(() => () => undefined), + removeItem: jest.fn(), + setOptions: jest.fn(), + subscribe: jest.fn(() => () => undefined), + update: jest.fn(), + } as unknown as OverflowManager, registerItem: jest.fn(), updateOverflow: jest.fn(), ...options, diff --git a/packages/react-components/react-overflow/library/src/useOverflowMenu.ts b/packages/react-components/react-overflow/library/src/useOverflowMenu.ts index e9451c78d9178b..4b245987c7366f 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowMenu.ts +++ b/packages/react-components/react-overflow/library/src/useOverflowMenu.ts @@ -11,22 +11,17 @@ export function useOverflowMenu( ): { ref: React.MutableRefObject; overflowCount: number; isOverflowing: boolean } { const elementId = useId('overflow-menu', id); const overflowCount = useOverflowCount(); - const registerOverflowMenu = useOverflowContext(v => v.registerOverflowMenu); - const updateOverflow = useOverflowContext(v => v.updateOverflow); + const { registerOverflowMenu, forceUpdateOverflow } = useOverflowContext(); const ref = React.useRef(null); const isOverflowing = overflowCount > 0; useIsomorphicLayoutEffect(() => { if (ref.current) { - return registerOverflowMenu(ref.current); + const unregister = registerOverflowMenu(ref.current); + forceUpdateOverflow(); + return unregister; } - }, [registerOverflowMenu, isOverflowing, elementId]); - - useIsomorphicLayoutEffect(() => { - if (isOverflowing) { - updateOverflow(); - } - }, [isOverflowing, updateOverflow, ref]); + }, [registerOverflowMenu, forceUpdateOverflow, isOverflowing, elementId]); return { ref, overflowCount, isOverflowing }; } 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..801eab63e07f83 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowVisibility.test.tsx +++ b/packages/react-components/react-overflow/library/src/useOverflowVisibility.test.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import type { OverflowManager } from '@fluentui/priority-overflow'; import { renderHook } from '@testing-library/react-hooks'; import { useOverflowVisibility } from './useOverflowVisibility'; import type { OverflowContextValue } from './overflowContext'; @@ -17,14 +18,28 @@ describe('useOverflowVisibility', () => { bar: true, baz: false, } as const; + const snapshot = { + hasOverflow: true, + overflowCount: 1, + itemVisibility, + groupVisibility, + }; + const getSnapshot = jest.fn(() => snapshot); + const subscribe = jest.fn(() => () => undefined); + + const manager = { + getSnapshot, + subscribe, + } as unknown as OverflowManager; const Wrapper: React.FC = props => { return ( diff --git a/packages/react-components/react-overflow/library/src/useOverflowVisibility.ts b/packages/react-components/react-overflow/library/src/useOverflowVisibility.ts index 6e406ff85ddb58..825a0dbb3a8469 100644 --- a/packages/react-components/react-overflow/library/src/useOverflowVisibility.ts +++ b/packages/react-components/react-overflow/library/src/useOverflowVisibility.ts @@ -1,7 +1,9 @@ 'use client'; +import type { OverflowGroupState } from '@fluentui/priority-overflow'; import * as React from 'react'; -import { useOverflowContext } from './overflowContext'; +import { _overflowPayloadToState } from './components/Overflow'; +import { useOverflowSnapshot } from './useOverflowSnapshot'; /** * A hook that returns the visibility status of all items and groups. @@ -14,10 +16,8 @@ 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(() => _overflowPayloadToState(snapshot), [snapshot]); }