perf(core): batch menu viewport measurements (#1823)

This commit is contained in:
Sam Potts
2026-07-17 08:19:54 +10:00
committed by GitHub
parent 0a8933c425
commit 72e175e1bd
2 changed files with 265 additions and 106 deletions
@@ -18,7 +18,7 @@ export interface MenuRootViewAttrs {
'data-menu-view': '';
}
interface MenuViewSize {
interface ViewSize {
width: number;
height: number;
}
@@ -29,29 +29,29 @@ interface InlineStyleSnapshotEntry {
priority: string;
}
interface PendingMenuViewTransition {
interface PendingViewTransition {
entering: HTMLElement;
availableWidth: number | null;
fromSize: MenuViewSize;
toSize: MenuViewSize;
fromSize: ViewSize;
toSize: ViewSize;
}
interface MenuViewportTransitionState {
pending: PendingMenuViewTransition | null;
interface ViewportTransitionState {
pending: PendingViewTransition | null;
phaseKeys: WeakMap<HTMLElement, string>;
}
const DEFAULT_MENU_VIEWPORT_MIN_WIDTH = 160;
const MENU_VIEW_ATTR = 'data-menu-view';
const MENU_VIEW_STATE_ATTR = 'data-menu-view-state';
const MENU_VIEW_ACTIVE_STATE = 'active';
const MENU_VIEW_INACTIVE_STATE = 'inactive';
const MENU_ROOT_VIEW_ATTR = 'data-menu-root-view';
const MENU_VIEWPORT_ATTR = 'data-menu-viewport';
const MENU_VIEW_LAYOUT_ATTRS = ['data-availability'];
const MENU_WIDTH_VAR = '--media-menu-width';
const MENU_HEIGHT_VAR = '--media-menu-height';
const MENU_VIEW_MEASURE_STYLE_PROPERTIES = [
const DEFAULT_MIN_WIDTH = 160;
const VIEW_ATTR = 'data-menu-view';
const VIEW_STATE_ATTR = 'data-menu-view-state';
const VIEW_ACTIVE_STATE = 'active';
const VIEW_INACTIVE_STATE = 'inactive';
const ROOT_VIEW_ATTR = 'data-menu-root-view';
const VIEWPORT_ATTR = 'data-menu-viewport';
const VIEW_LAYOUT_ATTRS = ['data-availability'];
const WIDTH_VAR = '--media-menu-width';
const HEIGHT_VAR = '--media-menu-height';
const VIEW_MEASURE_STYLE_PROPERTIES = [
'position',
'top',
'right',
@@ -63,7 +63,7 @@ const MENU_VIEW_MEASURE_STYLE_PROPERTIES = [
'max-width',
];
const viewportTransitionStates = new WeakMap<HTMLElement, MenuViewportTransitionState>();
const viewportTransitionStates = new WeakMap<HTMLElement, ViewportTransitionState>();
export function getMenuViewportAttrs(): MenuViewportAttrs {
return {
@@ -78,7 +78,7 @@ export function getMenuRootViewAttrs(): MenuRootViewAttrs {
};
}
function getViewportTransitionState(content: HTMLElement): MenuViewportTransitionState {
function getViewportTransitionState(content: HTMLElement): ViewportTransitionState {
let state = viewportTransitionStates.get(content);
if (!state) {
@@ -95,7 +95,7 @@ function getViewportTransitionState(content: HTMLElement): MenuViewportTransitio
export function getMenuViewportElement(content: HTMLElement | null): HTMLElement | null {
if (!content) return null;
return content.querySelector<HTMLElement>(`:scope > [${MENU_VIEWPORT_ATTR}]`) ?? content;
return content.querySelector<HTMLElement>(`:scope > [${VIEWPORT_ATTR}]`) ?? content;
}
function getViewportElement(content: HTMLElement, view?: HTMLElement | null): HTMLElement {
@@ -113,16 +113,16 @@ function getViewportElement(content: HTMLElement, view?: HTMLElement | null): HT
}
function getRootViewElement(viewport: HTMLElement): HTMLElement | null {
return viewport.querySelector<HTMLElement>(`:scope > [${MENU_ROOT_VIEW_ATTR}]`);
return viewport.querySelector<HTMLElement>(`:scope > [${ROOT_VIEW_ATTR}]`);
}
function getActiveMenuViewElement(viewport: HTMLElement): HTMLElement | null {
function getActiveViewElement(viewport: HTMLElement): HTMLElement | null {
return (
Array.from(viewport.children).find(
(child): child is HTMLElement =>
child instanceof HTMLElement &&
child.hasAttribute(MENU_VIEW_ATTR) &&
!child.hasAttribute(MENU_ROOT_VIEW_ATTR) &&
child.hasAttribute(VIEW_ATTR) &&
!child.hasAttribute(ROOT_VIEW_ATTR) &&
!child.hidden &&
!child.hasAttribute(TransitionDataAttrs.transitionEnding)
) ?? null
@@ -130,7 +130,7 @@ function getActiveMenuViewElement(viewport: HTMLElement): HTMLElement | null {
}
function resolveMinWidth(options: MenuViewportTransitionOptions | undefined): number {
return options?.minWidth ?? DEFAULT_MENU_VIEWPORT_MIN_WIDTH;
return options?.minWidth ?? DEFAULT_MIN_WIDTH;
}
function resolveAvailableWidth(
@@ -147,7 +147,7 @@ function resolveAvailableWidth(
}
function snapshotInlineStyle(element: HTMLElement): InlineStyleSnapshotEntry[] {
return MENU_VIEW_MEASURE_STYLE_PROPERTIES.map((property) => ({
return VIEW_MEASURE_STYLE_PROPERTIES.map((property) => ({
property,
value: element.style.getPropertyValue(property),
priority: element.style.getPropertyPriority(property),
@@ -164,89 +164,132 @@ function restoreInlineStyle(element: HTMLElement, snapshot: InlineStyleSnapshotE
}
}
function measureMenuView(
function measureViews(
content: HTMLElement,
views: readonly [HTMLElement],
minWidth: number,
options?: MenuViewportTransitionOptions
): [ViewSize];
function measureViews(
content: HTMLElement,
views: readonly [HTMLElement, HTMLElement],
minWidth: number,
options?: MenuViewportTransitionOptions
): [ViewSize, ViewSize];
function measureViews(
content: HTMLElement,
views: readonly HTMLElement[],
minWidth: number,
options?: MenuViewportTransitionOptions
): ViewSize[] {
const snapshots = views.map((view) => ({
view,
snapshot: snapshotInlineStyle(view),
}));
const availableWidth = resolveAvailableWidth(content, options);
try {
for (const { view } of snapshots) {
view.style.setProperty('position', 'absolute');
view.style.setProperty('top', '0px');
view.style.setProperty('right', 'auto');
view.style.setProperty('bottom', 'auto');
view.style.setProperty('left', '0px');
view.style.setProperty('width', 'max-content');
view.style.setProperty('height', 'auto');
view.style.setProperty('min-width', `${minWidth}px`);
view.style.setProperty('max-width', 'none');
}
const sizes = snapshots.map(({ view }) => {
const rect = view.getBoundingClientRect();
const naturalWidth = Math.ceil(Math.max(minWidth, rect.width, view.scrollWidth));
const width = Math.ceil(
availableWidth ? Math.max(minWidth, Math.min(naturalWidth, availableWidth)) : naturalWidth
);
return { view, rect, naturalWidth, width };
});
const constrained = sizes.filter((size) => size.width !== size.naturalWidth);
for (const { view, width } of constrained) {
view.style.setProperty('width', `${width}px`);
view.style.setProperty('max-width', `${width}px`);
}
for (const size of constrained) {
size.rect = size.view.getBoundingClientRect();
}
return sizes.map((size) => ({
width: size.width,
height: Math.ceil(Math.max(size.rect.height, size.view.scrollHeight)),
}));
} finally {
for (const { view, snapshot } of snapshots) restoreInlineStyle(view, snapshot);
}
}
function measureView(
content: HTMLElement,
view: HTMLElement,
minWidth: number,
options?: MenuViewportTransitionOptions
): MenuViewSize {
const snapshot = snapshotInlineStyle(view);
const availableWidth = resolveAvailableWidth(content, options);
try {
view.style.setProperty('position', 'absolute');
view.style.setProperty('top', '0px');
view.style.setProperty('right', 'auto');
view.style.setProperty('bottom', 'auto');
view.style.setProperty('left', '0px');
view.style.setProperty('width', 'max-content');
view.style.setProperty('height', 'auto');
view.style.setProperty('min-width', `${minWidth}px`);
view.style.setProperty('max-width', 'none');
forceLayout(view);
let rect = view.getBoundingClientRect();
const naturalWidth = Math.ceil(Math.max(minWidth, rect.width, view.scrollWidth));
const width = Math.ceil(availableWidth ? Math.max(minWidth, Math.min(naturalWidth, availableWidth)) : naturalWidth);
if (width !== naturalWidth) {
view.style.setProperty('width', `${width}px`);
view.style.setProperty('max-width', `${width}px`);
forceLayout(view);
rect = view.getBoundingClientRect();
}
return {
width,
height: Math.ceil(Math.max(rect.height, view.scrollHeight)),
};
} finally {
restoreInlineStyle(view, snapshot);
forceLayout(view);
}
): ViewSize {
return measureViews(content, [view], minWidth, options)[0];
}
function setViewportSize(content: HTMLElement, size: MenuViewSize): void {
content.style.setProperty(MENU_WIDTH_VAR, `${size.width}px`);
content.style.setProperty(MENU_HEIGHT_VAR, `${size.height}px`);
function setViewportSize(content: HTMLElement, size: ViewSize): void {
content.style.setProperty(WIDTH_VAR, `${size.width}px`);
content.style.setProperty(HEIGHT_VAR, `${size.height}px`);
}
function setMenuViewState(
view: HTMLElement,
state: typeof MENU_VIEW_ACTIVE_STATE | typeof MENU_VIEW_INACTIVE_STATE
): void {
view.setAttribute(MENU_VIEW_STATE_ATTR, state);
function setViewState(view: HTMLElement, state: typeof VIEW_ACTIVE_STATE | typeof VIEW_INACTIVE_STATE): void {
view.setAttribute(VIEW_STATE_ATTR, state);
if (state === MENU_VIEW_ACTIVE_STATE) {
if (state === VIEW_ACTIVE_STATE) {
view.setAttribute('data-open', '');
} else {
view.removeAttribute('data-open');
}
}
function prepareEnteringMenuView(
function prepareEnteringView(
content: HTMLElement,
rootView: HTMLElement,
entering: HTMLElement,
state: MenuViewportTransitionState,
minWidth: number,
availableWidth: number | null,
options?: MenuViewportTransitionOptions
): PendingViewTransition {
const [fromSize, toSize] = measureViews(content, [rootView, entering], minWidth, options);
return { entering, availableWidth, fromSize, toSize };
}
function prepareEnteringTransition(
content: HTMLElement,
rootView: HTMLElement,
entering: HTMLElement,
state: ViewportTransitionState,
options?: MenuViewportTransitionOptions
): void {
const minWidth = resolveMinWidth(options);
const availableWidth = resolveAvailableWidth(content, options);
const fromSize = measureMenuView(content, rootView, minWidth, options);
const toSize = measureMenuView(content, entering, minWidth, options);
state.pending = { entering, availableWidth, fromSize, toSize };
setMenuViewState(rootView, MENU_VIEW_ACTIVE_STATE);
setViewportSize(content, fromSize);
const pending = prepareEnteringView(content, rootView, entering, minWidth, availableWidth, options);
state.pending = pending;
setViewState(rootView, VIEW_ACTIVE_STATE);
setViewportSize(content, pending.fromSize);
forceLayout(content);
}
function startEnteringMenuView(
function startEnteringView(
content: HTMLElement,
rootView: HTMLElement,
entering: HTMLElement,
state: MenuViewportTransitionState,
state: ViewportTransitionState,
options?: MenuViewportTransitionOptions
): void {
const minWidth = resolveMinWidth(options);
@@ -254,39 +297,33 @@ function startEnteringMenuView(
const current =
state.pending?.entering === entering && state.pending.availableWidth === availableWidth
? state.pending
: {
entering,
availableWidth,
fromSize: measureMenuView(content, rootView, minWidth, options),
toSize: measureMenuView(content, entering, minWidth, options),
};
: prepareEnteringView(content, rootView, entering, minWidth, availableWidth, options);
state.pending = null;
setViewportSize(content, current.fromSize);
forceLayout(rootView);
setMenuViewState(rootView, MENU_VIEW_INACTIVE_STATE);
setViewState(rootView, VIEW_INACTIVE_STATE);
forceLayout(rootView);
setViewportSize(content, current.toSize);
}
function startExitingMenuView(
function startExitingView(
content: HTMLElement,
rootView: HTMLElement,
exiting: HTMLElement,
transitionState: MenuViewportTransitionState,
transitionState: ViewportTransitionState,
options?: MenuViewportTransitionOptions
): void {
transitionState.pending = null;
const minWidth = resolveMinWidth(options);
const fromSize = measureMenuView(content, exiting, minWidth, options);
const toSize = measureMenuView(content, rootView, minWidth, options);
const [fromSize, toSize] = measureViews(content, [exiting, rootView], minWidth, options);
setViewportSize(content, fromSize);
setMenuViewState(rootView, MENU_VIEW_INACTIVE_STATE);
setViewState(rootView, VIEW_INACTIVE_STATE);
forceLayout(rootView);
setMenuViewState(rootView, MENU_VIEW_ACTIVE_STATE);
setViewState(rootView, VIEW_ACTIVE_STATE);
forceLayout(rootView);
setViewportSize(content, toSize);
}
@@ -303,11 +340,11 @@ export function syncMenuViewRoot(
if (!rootView) return;
const activeView = getActiveMenuViewElement(viewport);
const activeView = getActiveViewElement(viewport);
if (activeView) {
if (rootView.getAttribute(MENU_VIEW_STATE_ATTR) === MENU_VIEW_INACTIVE_STATE) {
const size = measureMenuView(content, activeView, resolveMinWidth(options), options);
if (rootView.getAttribute(VIEW_STATE_ATTR) === VIEW_INACTIVE_STATE) {
const size = measureView(content, activeView, resolveMinWidth(options), options);
setViewportSize(content, size);
}
@@ -316,9 +353,9 @@ export function syncMenuViewRoot(
if (hasActiveChildView) return;
const size = measureMenuView(content, rootView, resolveMinWidth(options), options);
const size = measureView(content, rootView, resolveMinWidth(options), options);
setMenuViewState(rootView, MENU_VIEW_ACTIVE_STATE);
setViewState(rootView, VIEW_ACTIVE_STATE);
setViewportSize(content, size);
}
@@ -353,7 +390,7 @@ export function observeMenuViewContent(content: HTMLElement, onChange: () => voi
subtree: true,
attributes: true,
attributeOldValue: true,
attributeFilter: MENU_VIEW_LAYOUT_ATTRS,
attributeFilter: VIEW_LAYOUT_ATTRS,
});
return () => {
@@ -377,29 +414,31 @@ export function syncMenuViewTransition(
const state = getViewportTransitionState(content);
const phaseKey = `${viewState.phase}:${viewState.direction}`;
const previousPhaseKey = state.phaseKeys.get(view);
const shouldResyncActiveView =
viewState.phase === 'active' && rootView.getAttribute(MENU_VIEW_STATE_ATTR) !== MENU_VIEW_INACTIVE_STATE;
viewState.phase === 'active' && rootView.getAttribute(VIEW_STATE_ATTR) !== VIEW_INACTIVE_STATE;
if (state.phaseKeys.get(view) === phaseKey && !shouldResyncActiveView) return;
if (previousPhaseKey === phaseKey && !shouldResyncActiveView) return;
state.phaseKeys.set(view, phaseKey);
if (viewState.phase === 'hidden') {
state.phaseKeys.delete(view);
syncMenuViewRoot(content, getActiveMenuViewElement(viewport) !== null, options);
if (!previousPhaseKey || previousPhaseKey.startsWith('hidden:')) return;
syncMenuViewRoot(content, getActiveViewElement(viewport) !== null, options);
return;
}
if (viewState.phase === 'entering') {
prepareEnteringMenuView(content, rootView, view, state, options);
prepareEnteringTransition(content, rootView, view, state, options);
return;
}
if (viewState.phase === 'active') {
startEnteringMenuView(content, rootView, view, state, options);
startEnteringView(content, rootView, view, state, options);
return;
}
startExitingMenuView(content, rootView, view, state, options);
startExitingView(content, rootView, view, state, options);
}
@@ -221,6 +221,29 @@ describe('menu-viewport-transition', () => {
expect(content.style.getPropertyValue('--media-menu-width')).toBe('160px');
});
it('does not measure the root view for initially hidden child views', () => {
const content = addElement();
const rootView = document.createElement('div');
const menuViews = Array.from({ length: 13 }, () => document.createElement('div'));
applyAttrs(rootView, getMenuRootViewAttrs());
rootView.getBoundingClientRect = vi.fn(() => createRect(160, 100));
for (const menuView of menuViews) menuView.setAttribute('data-menu-view', '');
content.append(rootView, ...menuViews);
for (const menuView of menuViews) {
syncMenuViewTransition(content, menuView, {
phase: 'hidden',
direction: 'forward',
triggerId: null,
});
}
expect(rootView.getBoundingClientRect).not.toHaveBeenCalled();
expect(content.style.getPropertyValue('--media-menu-width')).toBe('');
expect(content.style.getPropertyValue('--media-menu-height')).toBe('');
});
it('measures the root view height at the available menu width', () => {
const content = addElement();
const rootView = document.createElement('div');
@@ -242,6 +265,61 @@ describe('menu-viewport-transition', () => {
expect(content.style.getPropertyValue('--media-menu-width')).toBe('180px');
expect(content.style.getPropertyValue('--media-menu-height')).toBe('128px');
expect(rootView.getBoundingClientRect).toHaveBeenCalledTimes(2);
});
it('avoids duplicate reads when measuring a natural menu view size', () => {
const content = addElement();
const rootView = document.createElement('div');
applyAttrs(rootView, getMenuRootViewAttrs());
content.append(rootView);
mockMenuViewSize(rootView, {
currentWidth: 160,
currentHeight: 80,
naturalWidth: 160,
naturalHeight: 80,
});
syncMenuViewRoot(content, false);
expect(rootView.getBoundingClientRect).toHaveBeenCalledTimes(1);
});
it('batches entering menu view measurements', () => {
const content = addElement();
const rootView = document.createElement('div');
const menuView = document.createElement('div');
applyAttrs(rootView, getMenuRootViewAttrs());
menuView.setAttribute('data-menu-view', '');
content.style.setProperty('--media-popover-available-width', '180px');
content.append(rootView, menuView);
mockMenuViewSize(rootView, {
currentWidth: 160,
currentHeight: 100,
naturalWidth: 160,
naturalHeight: 100,
});
mockMenuViewSize(menuView, {
currentWidth: 160,
currentHeight: 100,
naturalWidth: 260,
naturalHeight: 100,
constrainedWidth: 180,
constrainedHeight: 148,
});
syncMenuViewTransition(content, menuView, {
phase: 'entering',
direction: 'forward',
triggerId: 'trigger-1',
});
expect(rootView.getBoundingClientRect).toHaveBeenCalledTimes(1);
expect(menuView.getBoundingClientRect).toHaveBeenCalledTimes(2);
});
it('measures an entering submenu height at the available menu width', () => {
@@ -439,6 +517,48 @@ describe('menu-viewport-transition', () => {
expect(content.style.getPropertyValue('--media-menu-height')).toBe('109px');
expect(rootView.style.getPropertyValue('width')).toBe('');
expect(rootView.style.getPropertyValue('height')).toBe('');
expect(menuView.getBoundingClientRect).toHaveBeenCalledTimes(1);
expect(rootView.getBoundingClientRect).toHaveBeenCalledTimes(3);
});
it('resyncs the root view when a visible child view becomes hidden', () => {
const content = addElement();
const rootView = document.createElement('div');
const menuView = document.createElement('div');
applyAttrs(rootView, getMenuRootViewAttrs());
menuView.setAttribute('data-menu-view', '');
content.append(rootView, menuView);
mockMenuViewSize(rootView, {
currentWidth: 220,
currentHeight: 170,
naturalWidth: 160,
naturalHeight: 100,
});
mockMenuViewSize(menuView, {
currentWidth: 220,
currentHeight: 170,
naturalWidth: 220,
naturalHeight: 170,
});
syncMenuViewTransition(content, menuView, {
phase: 'active',
direction: 'forward',
triggerId: 'trigger-1',
});
menuView.hidden = true;
syncMenuViewTransition(content, menuView, {
phase: 'hidden',
direction: 'back',
triggerId: 'trigger-1',
});
expect(rootView.getAttribute('data-menu-view-state')).toBe('active');
expect(content.style.getPropertyValue('--media-menu-width')).toBe('160px');
expect(content.style.getPropertyValue('--media-menu-height')).toBe('100px');
});
it('does not restore the root view when a hidden child sibling still has an active view', () => {