Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions change/@fluentui-priority-overflow-pr2-subscribe.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "feat: expose getSnapshot and subscribe on OverflowManager",
"packageName": "@fluentui/priority-overflow",
"email": "bsunderhus@microsoft.com",
"dependentChangeType": "patch"
}
7 changes: 7 additions & 0 deletions change/@fluentui-react-overflow-pr2-subscribe.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "fix: subscribe overflow hooks directly to the manager snapshot",
"packageName": "@fluentui/react-overflow",
"email": "bsunderhus@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
// @internal
export function createOverflowManager(initialOptions?: Partial<ObserveOptions>): OverflowManager;

// @internal
export const EMPTY_SNAPSHOT: OverflowSnapshot;

// @public
export interface ObserveOptions {
hasHiddenItems?: boolean;
Expand Down Expand Up @@ -68,14 +71,23 @@ export interface OverflowManager {
addOverflowMenu: (element: HTMLElement) => void;
disconnect: () => void;
forceUpdate: () => void;
getSnapshot: () => OverflowSnapshot;
observe: (container: HTMLElement, options?: ObserveOptions) => void;
removeDivider: (groupId: string) => void;
removeItem: (itemId: string) => void;
removeOverflowMenu: () => void;
setOptions: (options: Partial<ObserveOptions>) => void;
subscribe: (listener: () => void) => () => void;
update: () => void;
}

// @public
export interface OverflowSnapshot {
groupVisibility: Record<string, OverflowGroupState>;
invisibleItemCount: number;
Comment thread
bsunderhus marked this conversation as resolved.
itemVisibility: Record<string, boolean>;
}

// (No @packageDocumentation comment for this package)

```
12 changes: 12 additions & 0 deletions packages/react-components/priority-overflow/src/consts.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,14 @@
import type { OverflowSnapshot } from './types';

export const DATA_OVERFLOWING = 'data-overflowing';
export const DATA_OVERFLOW_GROUP = 'data-overflow-group';

/**
* An empty, frozen overflow snapshot used as the default before anything has been measured.
* @internal
*/
export const EMPTY_SNAPSHOT: OverflowSnapshot = Object.freeze({
itemVisibility: {},
groupVisibility: {},
invisibleItemCount: 0,
});
2 changes: 2 additions & 0 deletions packages/react-components/priority-overflow/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { createOverflowManager } from './overflowManager';
export { EMPTY_SNAPSHOT } from './consts';
export type {
ObserveOptions,
OnUpdateItemVisibility,
Expand All @@ -11,4 +12,5 @@ export type {
OverflowItemEntry,
OverflowDividerEntry,
OverflowManager,
OverflowSnapshot,
} from './types';
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createOverflowManager } from './overflowManager';
import type { ObserveOptions, OverflowEventPayload } from './types';
import type { ObserveOptions } from './types';

describe('overflowManager', () => {
beforeAll(() => {
Expand Down Expand Up @@ -45,13 +45,20 @@ describe('overflowManager', () => {
...options,
});

const lastDispatch = (onUpdateOverflow: jest.Mock): OverflowEventPayload =>
onUpdateOverflow.mock.calls[onUpdateOverflow.mock.calls.length - 1][0];
const getVisibleIds = (manager: ReturnType<typeof createOverflowManager>) =>
Object.entries(manager.getSnapshot().itemVisibility)
.filter(([, visible]) => visible)
.map(([id]) => id)
.sort();

it('should dispatch overflow update after forceUpdate', () => {
const onUpdateOverflow = jest.fn();
const options = createObserveOptions({ onUpdateOverflow });
const manager = createOverflowManager(options);
const getInvisibleIds = (manager: ReturnType<typeof createOverflowManager>) =>
Object.entries(manager.getSnapshot().itemVisibility)
.filter(([, visible]) => !visible)
.map(([id]) => id)
.sort();

it('should expose a stable snapshot after forceUpdate', () => {
const manager = createOverflowManager(createObserveOptions());
const container = createContainer(100);
const itemA = createElementWithSize('button', 40);
const itemB = createElementWithSize('button', 40);
Expand All @@ -63,93 +70,90 @@ describe('overflowManager', () => {
manager.observe(container);
manager.forceUpdate();

const dispatch = lastDispatch(onUpdateOverflow);
expect(dispatch.visibleItems.map(item => item.id).sort()).toEqual(['a', 'b']);
expect(dispatch.invisibleItems).toEqual([]);
expect(dispatch.groupVisibility).toEqual({});
expect(getVisibleIds(manager)).toEqual(['a', 'b']);
expect(getInvisibleIds(manager)).toEqual([]);
expect(manager.getSnapshot().groupVisibility).toEqual({});
});

it('should re-dispatch when setOptions changes a relevant option', () => {
const onUpdateOverflow = jest.fn();
const options = createObserveOptions({ onUpdateOverflow });
const manager = createOverflowManager(options);
it('should update snapshot and notify subscribers when options change', () => {
const manager = createOverflowManager(createObserveOptions());
const container = createContainer(100);
const itemA = createElementWithSize('button', 40);
const itemB = createElementWithSize('button', 40);
const menu = createElementWithSize('button', 20);
const listener = jest.fn();

manager.addItem({ element: itemA, id: 'a', priority: 1 });
manager.addItem({ element: itemB, id: 'b', priority: 0 });
manager.addOverflowMenu(menu);
manager.observe(container);
manager.forceUpdate();
const unsubscribe = manager.subscribe(listener);

onUpdateOverflow.mockClear();
manager.setOptions({ padding: 30 });

expect(onUpdateOverflow).toHaveBeenCalled();
const dispatch = lastDispatch(onUpdateOverflow);
expect(dispatch.visibleItems.map(item => item.id)).toEqual(['a']);
expect(dispatch.invisibleItems.map(item => item.id)).toEqual(['b']);
expect(listener).toHaveBeenCalled();
expect(getVisibleIds(manager)).toEqual(['a']);
expect(getInvisibleIds(manager)).toEqual(['b']);
expect(manager.getSnapshot().groupVisibility).toEqual({});

unsubscribe();
});

it('should not re-dispatch when setOptions is called with a partial that does not change anything', () => {
const onUpdateOverflow = jest.fn();
const options = createObserveOptions({ onUpdateOverflow });
const manager = createOverflowManager(options);
it('should not notify subscribers when setOptions is called with a partial that does not change anything', () => {
const manager = createOverflowManager(createObserveOptions());
const container = createContainer(100);
const itemA = createElementWithSize('button', 40);

manager.addItem({ element: itemA, id: 'a', priority: 1 });
manager.observe(container, options);
manager.observe(container);
manager.forceUpdate();

onUpdateOverflow.mockClear();
const listener = jest.fn();
manager.subscribe(listener);
manager.setOptions({ padding: 10 }); // padding is already 10; no real change

expect(onUpdateOverflow).not.toHaveBeenCalled();
expect(listener).not.toHaveBeenCalled();
});

it('disconnect stops observation and re-observe restarts dispatching', () => {
const onUpdateOverflow = jest.fn();
const options = createObserveOptions({ onUpdateOverflow });
const manager = createOverflowManager(options);
it('should reset snapshot state when disconnect runs', () => {
const manager = createOverflowManager(createObserveOptions());
const container = createContainer(100);
const item = createElementWithSize('button', 40);

manager.addItem({ element: item, id: 'a', priority: 1 });
manager.observe(container);
manager.forceUpdate();
expect(lastDispatch(onUpdateOverflow).visibleItems.map(i => i.id)).toEqual(['a']);

expect(getVisibleIds(manager)).toEqual(['a']);

manager.disconnect();
onUpdateOverflow.mockClear();

manager.addItem({ element: item, id: 'a', priority: 1 });
manager.observe(container);
manager.forceUpdate();
expect(onUpdateOverflow).toHaveBeenCalled();
expect(lastDispatch(onUpdateOverflow).visibleItems.map(i => i.id)).toEqual(['a']);
expect(manager.getSnapshot()).toEqual({
itemVisibility: {},
groupVisibility: {},
invisibleItemCount: 0,
});
});

it('should remove items through removeItem', () => {
const onUpdateOverflow = jest.fn();
const options = createObserveOptions({ onUpdateOverflow });
const manager = createOverflowManager(options);
const manager = createOverflowManager(createObserveOptions());
const container = createContainer(100);
const item = createElementWithSize('button', 40);

manager.addItem({ element: item, id: 'a', priority: 1 });
manager.observe(container);
manager.forceUpdate();

expect(lastDispatch(onUpdateOverflow).visibleItems.map(i => i.id)).toEqual(['a']);
expect(getVisibleIds(manager)).toEqual(['a']);

manager.removeItem('a');
manager.forceUpdate();

const dispatch = lastDispatch(onUpdateOverflow);
expect(dispatch.visibleItems).toEqual([]);
expect(dispatch.invisibleItems).toEqual([]);
expect(manager.getSnapshot()).toEqual({
itemVisibility: {},
groupVisibility: {},
invisibleItemCount: 0,
});
});
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { DATA_OVERFLOWING, DATA_OVERFLOW_GROUP } from './consts';
import { DATA_OVERFLOWING, DATA_OVERFLOW_GROUP, EMPTY_SNAPSHOT } from './consts';
import { observeResize } from './createResizeObserver';
import { debounce } from './debounce';
import type { PriorityQueue } from './priorityQueue';
Expand All @@ -9,6 +9,7 @@ import type {
OverflowManager,
ObserveOptions,
OverflowDividerEntry,
OverflowSnapshot,
} from './types';

const DEFAULT_OPTIONS: Required<ObserveOptions> = {
Expand Down Expand Up @@ -46,10 +47,17 @@ export function createOverflowManager(initialOptions: Partial<ObserveOptions> =
const options: Required<ObserveOptions> = { ...DEFAULT_OPTIONS, ...initialOptions };
const overflowItems: Record<string, OverflowItemEntry> = {};
const overflowDividers: Record<string, OverflowDividerEntry> = {};
const listeners = new Set<() => void>();
let disposeResizeObserver: () => void = () => {
/* noop */
};

let snapshot: OverflowSnapshot = EMPTY_SNAPSHOT;
const takeSnapshot = (nextSnapshot: OverflowSnapshot) => {
snapshot = nextSnapshot;
listeners.forEach(listener => listener());
};

const getNextItem = (queueToDequeue: PriorityQueue<string>, queueToEnqueue: PriorityQueue<string>) => {
const nextItem = queueToDequeue.dequeue();
queueToEnqueue.enqueue(nextItem);
Expand Down Expand Up @@ -108,11 +116,10 @@ export function createOverflowManager(initialOptions: Partial<ObserveOptions> =
const visibleItemQueue = createPriorityQueue<string>(compareItems);

function occupiedSize(): number {
const totalItemSize = visibleItemQueue
.all()
.map(id => overflowItems[id].element)
.map(getOffsetSize)
.reduce((prev, current) => prev + current, 0);
let totalItemSize = 0;
for (const id of visibleItemQueue) {
totalItemSize += getOffsetSize(overflowItems[id].element);
}

const totalDividerSize = Object.entries(groupManager.groupVisibility()).reduce(
(acc, [id, state]) =>
Expand Down Expand Up @@ -153,15 +160,37 @@ export function createOverflowManager(initialOptions: Partial<ObserveOptions> =
};

const dispatchOverflowUpdate = () => {
const visibleItemIds = visibleItemQueue.all();
const invisibleItemIds = invisibleItemQueue.all();
const groupVisibility = groupManager.groupVisibility();

// Build the legacy ordered-entry arrays and the snapshot's id -> visible map in a single pass
// over each queue.
const itemVisibility: Record<string, boolean> = {};
const visibleItems: OverflowItemEntry[] = [];
const invisibleItems: OverflowItemEntry[] = [];

for (const itemId of visibleItemQueue) {
itemVisibility[itemId] = true;
visibleItems.push(overflowItems[itemId]);
}
for (const itemId of invisibleItemQueue) {
itemVisibility[itemId] = false;
invisibleItems.push(overflowItems[itemId]);
}

const visibleItems = visibleItemIds.map(itemId => overflowItems[itemId]);
const invisibleItems = invisibleItemIds.map(itemId => overflowItems[itemId]);
// Set the snapshot first so `getSnapshot()` is current for both subscribers and any
// `onUpdateOverflow` consumer that reads it.
takeSnapshot({
itemVisibility,
groupVisibility,
invisibleItemCount: invisibleItems.length,
});

options.onUpdateOverflow({ visibleItems, invisibleItems, groupVisibility: groupManager.groupVisibility() });
// Legacy event payload: ordered item entries for `onUpdateOverflow` consumers.
options.onUpdateOverflow({ visibleItems, invisibleItems, groupVisibility });
};

const getSnapshot: OverflowManager['getSnapshot'] = () => snapshot;

const processOverflowItems = (): boolean => {
if (!container) {
return false;
Expand Down Expand Up @@ -271,6 +300,9 @@ export function createOverflowManager(initialOptions: Partial<ObserveOptions> =
Object.keys(overflowDividers).forEach(dividerId => removeDivider(dividerId));
removeOverflowMenu();
sizeCache.clear();

// notify subscribers that the manager is no longer tracking anything
takeSnapshot(EMPTY_SNAPSHOT);
};

const addItem: OverflowManager['addItem'] = items => {
Expand Down Expand Up @@ -351,6 +383,14 @@ export function createOverflowManager(initialOptions: Partial<ObserveOptions> =
}
};

const subscribe: OverflowManager['subscribe'] = listener => {
listeners.add(listener);

return () => {
listeners.delete(listener);
};
};

return {
addItem,
disconnect,
Expand All @@ -363,6 +403,8 @@ export function createOverflowManager(initialOptions: Partial<ObserveOptions> =
addDivider,
removeDivider,
setOptions,
getSnapshot,
subscribe,
};
}

Expand Down
Loading
Loading