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-pr3-first-paint.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "fix: recompute overflow eagerly when the overflow menu attaches or detaches",
"packageName": "@fluentui/priority-overflow",
"email": "bsunderhus@microsoft.com",
"dependentChangeType": "patch"
}
7 changes: 7 additions & 0 deletions change/@fluentui-react-overflow-pr3-first-paint.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "feat: produce a correct overflow snapshot before the first paint",
"packageName": "@fluentui/react-overflow",
"email": "bsunderhus@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,14 @@
```ts

// @internal
export function createOverflowManager(initialOptions?: Partial<ObserveOptions>): OverflowManager;
export function createOverflowManager(initialOptions?: Partial<OverflowOptions>): OverflowManager;

// @internal
export const EMPTY_SNAPSHOT: OverflowSnapshot;

// @public
export interface ObserveOptions {
hasHiddenItems?: boolean;
minimumVisible?: number;
onUpdateItemVisibility: OnUpdateItemVisibility;
onUpdateOverflow: OnUpdateOverflow;
overflowAxis?: OverflowAxis;
overflowDirection?: OverflowDirection;
padding?: number;
// @public (undocumented)
export interface ObserveOptions extends Partial<OverflowOptions> {
forceUpdate?: boolean;
}

// @public
Expand Down Expand Up @@ -76,11 +70,22 @@ export interface OverflowManager {
removeDivider: (groupId: string) => void;
removeItem: (itemId: string) => void;
removeOverflowMenu: () => void;
setOptions: (options: Partial<ObserveOptions>) => void;
setOptions: (options: Partial<OverflowOptions>) => void;
subscribe: (listener: () => void) => () => void;
update: () => void;
}

// @public
export interface OverflowOptions {
hasHiddenItems?: boolean;
minimumVisible?: number;
onUpdateItemVisibility: OnUpdateItemVisibility;
onUpdateOverflow: OnUpdateOverflow;
overflowAxis?: OverflowAxis;
overflowDirection?: OverflowDirection;
padding?: number;
}

// @public
export interface OverflowSnapshot {
groupVisibility: Record<string, OverflowGroupState>;
Expand Down
1 change: 1 addition & 0 deletions packages/react-components/priority-overflow/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export { createOverflowManager } from './overflowManager';
export { EMPTY_SNAPSHOT } from './consts';
export type {
ObserveOptions,
OverflowOptions,
OnUpdateItemVisibility,
OnUpdateItemVisibilityPayload,
OnUpdateOverflow,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,25 @@ describe('overflowManager', () => {
});
});

it('should re-dispatch when the overflow menu is attached while observing', () => {
const onUpdateOverflow = jest.fn();
const manager = createOverflowManager(createObserveOptions({ onUpdateOverflow }));
const container = createContainer(100);
const itemA = createElementWithSize('button', 60);
const itemB = createElementWithSize('button', 60);

manager.addItem({ element: itemA, id: 'a', priority: 1 });
manager.addItem({ element: itemB, id: 'b', priority: 0 });
manager.observe(container);
manager.forceUpdate();
onUpdateOverflow.mockClear();

const menu = createElementWithSize('button', 30);
manager.addOverflowMenu(menu);

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

it('should remove items through removeItem', () => {
const manager = createOverflowManager(createObserveOptions());
const container = createContainer(100);
Expand All @@ -156,4 +175,52 @@ describe('overflowManager', () => {
invisibleItemCount: 0,
});
});

it('resolves overflow synchronously when observed with forceUpdate and the container is measured', () => {
const manager = createOverflowManager(createObserveOptions());
const container = createContainer(100);
const itemA = createElementWithSize('button', 60);
const itemB = createElementWithSize('button', 60);
const menu = createElementWithSize('button', 30);

manager.addItem({ element: itemA, id: 'a', priority: 1 });
manager.addItem({ element: itemB, id: 'b', priority: 0 });
manager.addOverflowMenu(menu);

// No manual forceUpdate(); observing with forceUpdate resolves overflow on its own.
manager.observe(container, { forceUpdate: true });

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

it('does not resolve overflow on observe when forceUpdate is not requested', () => {
const manager = createOverflowManager(createObserveOptions());
const container = createContainer(100);
const itemA = createElementWithSize('button', 60);
const itemB = createElementWithSize('button', 60);

manager.addItem({ element: itemA, id: 'a', priority: 1 });
manager.addItem({ element: itemB, id: 'b', priority: 0 });

manager.observe(container);

// Nothing has been computed yet (the ResizeObserver is mocked to a noop).
expect(manager.getSnapshot().itemVisibility).toEqual({});
});

it('does not resolve overflow on observe with forceUpdate when the container is not measured', () => {
const manager = createOverflowManager(createObserveOptions());
const container = createContainer(0);
const itemA = createElementWithSize('button', 60);
const itemB = createElementWithSize('button', 60);

manager.addItem({ element: itemA, id: 'a', priority: 1 });
manager.addItem({ element: itemB, id: 'b', priority: 0 });

// Degenerate 0 size — the guard skips the force so nothing collapses.
manager.observe(container, { forceUpdate: true });

expect(manager.getSnapshot().itemVisibility).toEqual({});
});
});
30 changes: 22 additions & 8 deletions packages/react-components/priority-overflow/src/overflowManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ import type {
OverflowGroupState,
OverflowItemEntry,
OverflowManager,
ObserveOptions,
OverflowOptions,
OverflowDividerEntry,
OverflowSnapshot,
} from './types';

const DEFAULT_OPTIONS: Required<ObserveOptions> = {
const DEFAULT_OPTIONS: Required<OverflowOptions> = {
overflowAxis: 'horizontal',
overflowDirection: 'end',
padding: 10,
Expand All @@ -33,7 +33,7 @@ const DEFAULT_OPTIONS: Required<ObserveOptions> = {
* @param initialOptions - Initial observe options. Missing values are filled with defaults.
* @returns overflow manager instance
*/
export function createOverflowManager(initialOptions: Partial<ObserveOptions> = {}): OverflowManager {
export function createOverflowManager(initialOptions: Partial<OverflowOptions> = {}): 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<HTMLElement, number>();
Expand All @@ -44,7 +44,7 @@ export function createOverflowManager(initialOptions: Partial<ObserveOptions> =
// 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<ObserveOptions> = { ...DEFAULT_OPTIONS, ...initialOptions };
const options: Required<OverflowOptions> = { ...DEFAULT_OPTIONS, ...initialOptions };
const overflowItems: Record<string, OverflowItemEntry> = {};
const overflowDividers: Record<string, OverflowDividerEntry> = {};
const listeners = new Set<() => void>();
Expand Down Expand Up @@ -264,10 +264,10 @@ export function createOverflowManager(initialOptions: Partial<ObserveOptions> =
}
};

const observe: OverflowManager['observe'] = (observedContainer, userOptions) => {
if (userOptions) {
Object.assign(options, userOptions);
}
const observe: OverflowManager['observe'] = (observedContainer, observeOptions) => {
const { forceUpdate: shouldForceUpdate, ...userOptions } = observeOptions ?? {};
Object.assign(options, userOptions);

Object.values(overflowItems).forEach(item => {
if (!visibleItemQueue.contains(item.id) && !invisibleItemQueue.contains(item.id)) {
visibleItemQueue.enqueue(item.id);
Expand All @@ -282,6 +282,10 @@ export function createOverflowManager(initialOptions: Partial<ObserveOptions> =
}
update();
});

if (shouldForceUpdate && getClientSize(observedContainer) > 0) {
forceUpdate();
}
};

const disconnect: OverflowManager['disconnect'] = () => {
Expand Down Expand Up @@ -330,6 +334,11 @@ export function createOverflowManager(initialOptions: Partial<ObserveOptions> =

const addOverflowMenu: OverflowManager['addOverflowMenu'] = el => {
overflowMenu = el;

if (observing) {
forceDispatch = true;
update();
}
};

const addDivider: OverflowManager['addDivider'] = divider => {
Expand All @@ -343,6 +352,11 @@ export function createOverflowManager(initialOptions: Partial<ObserveOptions> =

const removeOverflowMenu: OverflowManager['removeOverflowMenu'] = () => {
overflowMenu = undefined;

if (observing) {
forceDispatch = true;
update();
}
};

const removeDivider: OverflowManager['removeDivider'] = groupId => {
Expand Down
12 changes: 10 additions & 2 deletions packages/react-components/priority-overflow/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ export interface OnUpdateItemVisibilityPayload {
/**
* Options used to initialize or reconfigure overflow observation.
*/
export interface ObserveOptions {
export interface OverflowOptions {
/**
* 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)
Expand Down Expand Up @@ -172,6 +172,14 @@ export interface ObserveOptions {
hasHiddenItems?: boolean;
}

export interface ObserveOptions extends Partial<OverflowOptions> {
/**
* forces update when observation begins, ensuring initial overflow state is correct. This is useful when the container starts with items that should be overflowed, or when the container resizes immediately after mounting.
* @default false
*/
forceUpdate?: boolean;
}

/**
* Internal manager contract used to observe and compute priority overflow.
*
Expand All @@ -189,7 +197,7 @@ export interface OverflowManager {
/**
* Updates engine options without restarting observation.
*/
setOptions: (options: Partial<ObserveOptions>) => void;
setOptions: (options: Partial<OverflowOptions>) => void;
/**
* Add overflow items
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@

```ts

import type { ObserveOptions } from '@fluentui/priority-overflow';
import type { OnUpdateOverflow } from '@fluentui/priority-overflow';
import type { OverflowDividerEntry } from '@fluentui/priority-overflow';
import type { OverflowGroupState } from '@fluentui/priority-overflow';
import type { OverflowItemEntry } from '@fluentui/priority-overflow';
import type { OverflowOptions } from '@fluentui/priority-overflow';
import type { OverflowSnapshot } from '@fluentui/priority-overflow';
import * as React_2 from 'react';

Expand All @@ -29,7 +29,7 @@ export interface OnOverflowChangeData extends OverflowState {
}

// @public
export const Overflow: React_2.ForwardRefExoticComponent<Partial<Pick<ObserveOptions, "padding" | "overflowDirection" | "overflowAxis" | "minimumVisible" | "hasHiddenItems">> & {
export const Overflow: React_2.ForwardRefExoticComponent<Partial<Pick<OverflowOptions, "padding" | "overflowDirection" | "overflowAxis" | "minimumVisible" | "hasHiddenItems">> & {
children: React_2.ReactElement;
onOverflowChange?: (ev: null, data: OverflowState) => void;
} & React_2.RefAttributes<unknown>>;
Expand All @@ -54,7 +54,7 @@ export type OverflowItemProps = {
});

// @public
export type OverflowProps = Partial<Pick<ObserveOptions, 'overflowAxis' | 'overflowDirection' | 'padding' | 'minimumVisible' | 'hasHiddenItems'>> & {
export type OverflowProps = Partial<Pick<OverflowOptions, 'overflowAxis' | 'overflowDirection' | 'padding' | 'minimumVisible' | 'hasHiddenItems'>> & {
children: React_2.ReactElement;
onOverflowChange?: (ev: null, data: OverflowState) => void;
};
Expand All @@ -69,10 +69,10 @@ export function useIsOverflowGroupVisible(id: string): OverflowGroupState;
export function useIsOverflowItemVisible(id: string): boolean;

// @internal (undocumented)
export const useOverflowContainer: <TElement extends HTMLElement>(update: OnUpdateOverflow, options: Omit<ObserveOptions, "onUpdateOverflow">) => UseOverflowContainerReturn<TElement>;
export const useOverflowContainer: <TElement extends HTMLElement>(update: OnUpdateOverflow, options: Omit<OverflowOptions, "onUpdateOverflow">) => UseOverflowContainerReturn<TElement>;

// @internal (undocumented)
export interface UseOverflowContainerReturn<TElement extends HTMLElement> extends Pick<OverflowContextValue, 'registerItem' | 'updateOverflow' | 'registerOverflowMenu' | 'registerDivider' | 'getSnapshot' | 'subscribe'> {
export interface UseOverflowContainerReturn<TElement extends HTMLElement> extends Pick<OverflowContextValue, 'registerItem' | 'updateOverflow' | 'forceUpdateOverflow' | 'registerOverflowMenu' | 'registerDivider' | 'getSnapshot' | 'subscribe'> {
containerRef: React_2.RefObject<TElement | null>;
}

Expand Down
Loading
Loading