From e938ee1f866ee0fbd14a806ca2c1c81e006ed6f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Krassowski?= <5832902+krassowski@users.noreply.github.com> Date: Fri, 7 Oct 2022 08:55:02 +0100 Subject: [PATCH] Backport PR #432: Avoid menu layout trashing by moving DOM queries * Move/cache DOM queries to prevent menu layout trashing The DOM queries will execute before subsequent DOM modifications to avoid layout trashing when opening or switching menus. * Use simpler transient cache, move update requests (cherry picked from commit 55e87caa277ac3ae3d15b8c5da75e78d54b069ff) --- packages/virtualdom/src/index.ts | 5 +- packages/widgets/src/contextmenu.ts | 3 + packages/widgets/src/menu.ts | 106 +++++++++++++++++------- packages/widgets/src/menubar.ts | 68 ++++++++++++--- packages/widgets/tests/src/menu.spec.ts | 17 ++-- review/api/widgets.api.md | 1 + 6 files changed, 153 insertions(+), 47 deletions(-) diff --git a/packages/virtualdom/src/index.ts b/packages/virtualdom/src/index.ts index 1f344edc6..c3c57b43c 100644 --- a/packages/virtualdom/src/index.ts +++ b/packages/virtualdom/src/index.ts @@ -1349,7 +1349,10 @@ namespace Private { // Handle the simplest case of in-place text update first. if (oldVNode.type === 'text' && newVNode.type === 'text') { - currElem!.textContent = newVNode.content; + // Avoid spurious updates for performance. + if (currElem!.textContent !== newVNode.content) { + currElem!.textContent = newVNode.content; + } currElem = currElem!.nextSibling; continue; } diff --git a/packages/widgets/src/contextmenu.ts b/packages/widgets/src/contextmenu.ts index d62ea1ab5..de1cf936b 100644 --- a/packages/widgets/src/contextmenu.ts +++ b/packages/widgets/src/contextmenu.ts @@ -78,6 +78,9 @@ export class ContextMenu { * position indicated by the event. */ open(event: MouseEvent): boolean { + // Prior to any DOM modifications update the window data. + Menu.saveWindowData(); + // Clear the current contents of the context menu. this.menu.clearItems(); diff --git a/packages/widgets/src/menu.ts b/packages/widgets/src/menu.ts index 9d51d9eca..f1613da23 100644 --- a/packages/widgets/src/menu.ts +++ b/packages/widgets/src/menu.ts @@ -32,6 +32,13 @@ import { import { Widget } from './widget'; +interface IWindowData { + pageXOffset: number; + pageYOffset: number; + clientWidth: number; + clientHeight: number; +} + /** * A widget which displays items as a canonical menu. */ @@ -829,6 +836,9 @@ export class Menu extends Widget { return; } + // Prior to any DOM modifications save window data + Menu.saveWindowData(); + // Ensure the current child menu is closed. this._closeChildMenu(); @@ -911,6 +921,19 @@ export class Menu extends Widget { } } + /** + * Save window data used for menu positioning in transient cache. + * + * In order to avoid layout trashing it is recommended to invoke this + * method immediately prior to opening the menu and any DOM modifications + * (like closing previously visible menu, or adding a class to menu widget). + * + * The transient cache will be released upon `open()` call. + */ + static saveWindowData(): void { + Private.saveWindowData(); + } + private _childIndex = -1; private _activeIndex = -1; private _openTimerID = 0; @@ -1439,6 +1462,32 @@ namespace Private { */ export const SUBMENU_OVERLAP = 3; + let transientWindowDataCache: IWindowData | null = null; + let transientCacheCounter: number = 0; + + function getWindowData(): IWindowData { + // if transient cache is in use, take one from it + if (transientCacheCounter > 0) { + transientCacheCounter--; + return transientWindowDataCache!; + } + return _getWindowData(); + } + + /** + * Store window data in transient cache. + * + * The transient cache will be released upon `getWindowData()` call. + * If this function is called multiple times, the cache will be + * retained until as many calls to `getWindowData()` were made. + * + * Note: should be called before any DOM modifications. + */ + export function saveWindowData(): void { + transientWindowDataCache = _getWindowData(); + transientCacheCounter++; + } + /** * Create the DOM node for a menu. */ @@ -1541,6 +1590,15 @@ namespace Private { return result; } + function _getWindowData(): IWindowData { + return { + pageXOffset: window.pageXOffset, + pageYOffset: window.pageYOffset, + clientWidth: document.documentElement.clientWidth, + clientHeight: document.documentElement.clientHeight + }; + } + /** * Open a menu as a root menu at the target location. */ @@ -1551,15 +1609,16 @@ namespace Private { forceX: boolean, forceY: boolean ): void { + // Get the current position and size of the main viewport. + const windowData = getWindowData(); + let px = windowData.pageXOffset; + let py = windowData.pageYOffset; + let cw = windowData.clientWidth; + let ch = windowData.clientHeight; + // Ensure the menu is updated before attaching and measuring. MessageLoop.sendMessage(menu, Widget.Msg.UpdateRequest); - // Get the current position and size of the main viewport. - let px = window.pageXOffset; - let py = window.pageYOffset; - let cw = document.documentElement.clientWidth; - let ch = document.documentElement.clientHeight; - // Compute the maximum allowed height for the menu. let maxHeight = ch - (forceY ? y : 0); @@ -1568,11 +1627,7 @@ namespace Private { let style = node.style; // Clear the menu geometry and prepare it for measuring. - style.top = ''; - style.left = ''; - style.width = ''; - style.height = ''; - style.visibility = 'hidden'; + style.opacity = '0'; style.maxHeight = `${maxHeight}px`; // Attach the menu to the document. @@ -1596,26 +1651,26 @@ namespace Private { } // Update the position of the menu to the computed position. - style.top = `${Math.max(0, y)}px`; - style.left = `${Math.max(0, x)}px`; + style.transform = `translate(${Math.max(0, x)}px, ${Math.max(0, y)}px`; // Finally, make the menu visible on the screen. - style.visibility = ''; + style.opacity = '1'; } /** * Open a menu as a submenu using an item node for positioning. */ export function openSubmenu(submenu: Menu, itemNode: HTMLElement): void { + // Get the current position and size of the main viewport. + const windowData = getWindowData(); + let px = windowData.pageXOffset; + let py = windowData.pageYOffset; + let cw = windowData.clientWidth; + let ch = windowData.clientHeight; + // Ensure the menu is updated before opening. MessageLoop.sendMessage(submenu, Widget.Msg.UpdateRequest); - // Get the current position and size of the main viewport. - let px = window.pageXOffset; - let py = window.pageYOffset; - let cw = document.documentElement.clientWidth; - let ch = document.documentElement.clientHeight; - // Compute the maximum allowed height for the menu. let maxHeight = ch; @@ -1624,11 +1679,7 @@ namespace Private { let style = node.style; // Clear the menu geometry and prepare it for measuring. - style.top = ''; - style.left = ''; - style.width = ''; - style.height = ''; - style.visibility = 'hidden'; + style.opacity = '0'; style.maxHeight = `${maxHeight}px`; // Attach the menu to the document. @@ -1660,11 +1711,10 @@ namespace Private { } // Update the position of the menu to the computed position. - style.top = `${Math.max(0, y)}px`; - style.left = `${Math.max(0, x)}px`; + style.transform = `translate(${Math.max(0, x)}px, ${Math.max(0, y)}px`; // Finally, make the menu visible on the screen. - style.visibility = ''; + style.opacity = '1'; } /** diff --git a/packages/widgets/src/menubar.ts b/packages/widgets/src/menubar.ts index 0a88bfb10..922dfef9f 100644 --- a/packages/widgets/src/menubar.ts +++ b/packages/widgets/src/menubar.ts @@ -523,8 +523,11 @@ export class MenuBar extends Widget { this._closeChildMenu(); this.activeIndex = index; } else { + const position = this._positionForMenu(index); + Menu.saveWindowData(); + // Begin DOM modifications. this.activeIndex = index; - this._openChildMenu(); + this._openChildMenu(position); } } @@ -549,15 +552,39 @@ export class MenuBar extends Widget { return; } + // Get position for the new menu >before< updating active index. + const position = this._positionForMenu(index); + + // Before any modification, update window data. + Menu.saveWindowData(); + + // Begin DOM modifications. + // Update the active index to the hovered item. this.activeIndex = index; // Open the new menu if a menu is already open. if (this._childMenu) { - this._openChildMenu(); + this._openChildMenu(position); } } + /** + * Find initial position for the menu based on menubar item position. + * + * NOTE: this should be called before updating active index to avoid + * an additional layout and style invalidation as changing active + * index modifies DOM. + */ + private _positionForMenu(index: number): Private.IPosition { + let itemNode = this.contentNode.children[index]; + let { left, bottom } = (itemNode as HTMLElement).getBoundingClientRect(); + return { + top: bottom, + left + }; + } + /** * Handle the `'mouseleave'` event for the menu bar. */ @@ -574,7 +601,7 @@ export class MenuBar extends Widget { * If a different child menu is already open, it will be closed, * even if there is no active menu. */ - private _openChildMenu(): void { + private _openChildMenu(options: { left?: number; top?: number } = {}): void { // If there is no active menu, close the current menu. let newMenu = this.activeMenu; if (!newMenu) { @@ -595,23 +622,30 @@ export class MenuBar extends Widget { if (oldMenu) { oldMenu.close(); } else { - this.addClass('lm-mod-active'); - /* */ - this.addClass('p-mod-active'); - /* */ document.addEventListener('mousedown', this, true); } // Ensure the menu bar is updated and look up the item node. MessageLoop.sendMessage(this, Widget.Msg.UpdateRequest); - let itemNode = this.contentNode.children[this._activeIndex]; // Get the positioning data for the new menu. - let { left, bottom } = (itemNode as HTMLElement).getBoundingClientRect(); + let { left, top } = options; + if (typeof left === 'undefined' || typeof top === 'undefined') { + ({ left, top } = this._positionForMenu(this._activeIndex)); + } + // Begin DOM modifications + + if (!oldMenu) { + // Continue setup for new menu + this.addClass('lm-mod-active'); + /* */ + this.addClass('p-mod-active'); + /* */ + } // Open the new menu at the computed location. if (newMenu.items.length > 0) { - newMenu.open(left, bottom, this._forceItemsPosition); + newMenu.open(left, top, this._forceItemsPosition); } } @@ -961,6 +995,20 @@ namespace Private { return node; } + /** + * Position for the menu relative to top-left screen corner. + */ + export interface IPosition { + /** + * Pixels right from screen origin. + */ + left: number; + /** + * Pixels down from screen origin. + */ + top: number; + } + /** * The results of a mnemonic search. */ diff --git a/packages/widgets/tests/src/menu.spec.ts b/packages/widgets/tests/src/menu.spec.ts index ed8dc4199..f47729a3e 100644 --- a/packages/widgets/tests/src/menu.spec.ts +++ b/packages/widgets/tests/src/menu.spec.ts @@ -558,30 +558,31 @@ describe('@lumino/widgets', () => { it('should open the menu at the specified location', () => { menu.addItem({ command: 'test' }); menu.open(10, 10); - expect(menu.node.style.left).to.equal('10px'); - expect(menu.node.style.top).to.equal('10px'); + expect(menu.node.style.transform).to.equal('translate(10px, 10px)'); }); it('should be adjusted to fit naturally on the screen', () => { menu.addItem({ command: 'test' }); menu.open(-10, 10000); - expect(menu.node.style.left).to.equal('0px'); - expect(menu.node.style.top).to.not.equal('10000px'); + expect(menu.node.style.transform.indexOf('translate(0px, ')).to.equal( + 0 + ); + expect(menu.node.style.transform.indexOf(', 10000px)')).to.equal(-1); }); it('should accept flags to force the location', () => { menu.addItem({ command: 'test' }); menu.open(10000, 10000, { forceX: true, forceY: true }); - expect(menu.node.style.left).to.equal('10000px'); - expect(menu.node.style.top).to.equal('10000px'); + expect(menu.node.style.transform).to.equal( + 'translate(10000px, 10000px)' + ); }); it('should bail if already attached', () => { menu.addItem({ command: 'test' }); menu.open(10, 10); menu.open(100, 100); - expect(menu.node.style.left).to.equal('10px'); - expect(menu.node.style.top).to.equal('10px'); + expect(menu.node.style.transform).to.equal('translate(10px, 10px)'); }); }); diff --git a/review/api/widgets.api.md b/review/api/widgets.api.md index da5c9d8c9..c8b242d69 100644 --- a/review/api/widgets.api.md +++ b/review/api/widgets.api.md @@ -584,6 +584,7 @@ export class Menu extends Widget { removeItemAt(index: number): void; readonly renderer: Menu.IRenderer; readonly rootMenu: Menu; + static saveWindowData(): void; triggerActiveItem(): void; }