Skip to content

Commit 0619c13

Browse files
authored
refactor!: avoid forced style recalculations on app-layout resize (#11493)
## Description The PR consolidates a window resize listener and a separate `ResizeObserver` into a single `ResizeObserver` that reads values in the observer callback while DOM updates are deferred to `requestAnimationFrame`. This helps avoid forced style recalculations on app layout resize. | Before | After | |--------|------| | <img width="1512" height="1910" alt="Screenshot 2026-04-16 at 15 25 55" src="https://github.com/user-attachments/assets/3b52706e-b6f5-498a-b451-2d63ee72e7a6" /> | <img width="1512" height="1910" alt="Screenshot 2026-04-16 at 15 27 14" src="https://github.com/user-attachments/assets/a3637f31-ba5e-46ec-8052-f6ff49b438f9" /> | The screenshots show resizing the Starpass app before and after with 4x CPU throttle. > [!WARNING] > There is a small risk of this being a breaking change, as it it alters the timing of when attributes are set. ## Type of change - Refactoring
1 parent d7d7237 commit 0619c13

9 files changed

Lines changed: 141 additions & 122 deletions

File tree

packages/app-layout/src/vaadin-app-layout-mixin.js

Lines changed: 71 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,6 @@ export const AppLayoutMixin = (superclass) =>
120120
constructor() {
121121
super();
122122
// TODO(jouni): might want to debounce
123-
this.__boundResizeListener = this._resize.bind(this);
124123
this.__drawerToggleClickListener = this._drawerToggleClick.bind(this);
125124
this.__onDrawerKeyDown = this.__onDrawerKeyDown.bind(this);
126125
this.__closeOverlayDrawerListener = this.__closeOverlayDrawer.bind(this);
@@ -138,34 +137,13 @@ export const AppLayoutMixin = (superclass) =>
138137
connectedCallback() {
139138
super.connectedCallback();
140139

141-
this._blockAnimationUntilAfterNextRender();
140+
this.__resizeObserver = new ResizeObserver((entries) => this.__onResize(entries));
141+
this.__resizeObserver.observe(this);
142+
this.__resizeObserver.observe(this.$.drawer);
143+
this.__resizeObserver.observe(this.$.navbarTop);
144+
this.__resizeObserver.observe(this.$.navbarBottom);
142145

143-
window.addEventListener('resize', this.__boundResizeListener);
144146
this.addEventListener('drawer-toggle-click', this.__drawerToggleClickListener);
145-
146-
requestAnimationFrame(() => {
147-
this._updateOffsetSize();
148-
});
149-
150-
this._updateTouchOptimizedMode();
151-
this._updateDrawerSize();
152-
this._updateOverlayMode();
153-
154-
this._navbarSizeObserver = new ResizeObserver(() => {
155-
requestAnimationFrame(() => {
156-
// Prevent updating offset size multiple times
157-
// during the drawer open / close transition.
158-
if (this.__isDrawerAnimating) {
159-
this.__updateOffsetSizePending = true;
160-
} else {
161-
this._updateOffsetSize();
162-
}
163-
});
164-
});
165-
this._navbarSizeObserver.observe(this.$.navbarTop);
166-
this._navbarSizeObserver.observe(this.$.navbarBottom);
167-
this._navbarSizeObserver.observe(this.$.drawer);
168-
169147
window.addEventListener('close-overlay-drawer', this.__closeOverlayDrawerListener);
170148
window.addEventListener('keydown', this.__onDrawerKeyDown);
171149
}
@@ -181,42 +159,70 @@ export const AppLayoutMixin = (superclass) =>
181159
});
182160

183161
this.$.drawer.addEventListener('transitionend', () => {
184-
// Update offset size after drawer animation.
185-
if (this.__updateOffsetSizePending) {
186-
this.__updateOffsetSizePending = false;
187-
this._updateOffsetSize();
188-
}
189-
190-
// Delay resetting the flag until animation frame
191-
// to avoid updating offset size again on resize.
192-
requestAnimationFrame(() => {
193-
this.__isDrawerAnimating = false;
194-
});
162+
this.__isDrawerAnimating = false;
163+
this.__scheduleResize(this.$.drawer);
195164
});
196165
}
197166

198167
/** @protected */
199168
disconnectedCallback() {
200169
super.disconnectedCallback();
201-
202-
window.removeEventListener('resize', this.__boundResizeListener);
170+
this.__resizeObserver.disconnect();
203171
this.removeEventListener('drawer-toggle-click', this.__drawerToggleClickListener);
204172
window.removeEventListener('close-overlay-drawer', this.__drawerToggleClickListener);
205173
window.removeEventListener('keydown', this.__onDrawerKeyDown);
206174
}
207175

208176
/** @private */
209177
__onNavbarSlotChange() {
210-
this._updateTouchOptimizedMode();
178+
this.__scheduleResize(this.$.navbarTop);
179+
this.__scheduleResize(this.$.navbarBottom);
211180
this.toggleAttribute('has-navbar', !!this.querySelector('[slot="navbar"]'));
212181
}
213182

214183
/** @private */
215184
__onDrawerSlotChange() {
216-
this._updateDrawerSize();
185+
this.__scheduleResize(this.$.drawer);
186+
this.__updateDrawerSize();
217187
this.toggleAttribute('has-drawer', !!this.querySelector('[slot="drawer"]'));
218188
}
219189

190+
/** @private */
191+
__onResize(entries) {
192+
cancelAnimationFrame(this.__resizeRaf);
193+
194+
const isHostResized = entries.some(({ target }) => target === this);
195+
const isNavbarResized = entries.some(({ target }) => [this.$.navbarTop, this.$.navbarBottom].includes(target));
196+
197+
const overlayMode = this._getCustomPropertyValue('--vaadin-app-layout-drawer-overlay') === 'true';
198+
const touchOptimized = this._getCustomPropertyValue('--vaadin-app-layout-touch-optimized') === 'true';
199+
200+
const drawerRect = this.$.drawer.getBoundingClientRect();
201+
const navbarTopRect = this.$.navbarTop.getBoundingClientRect();
202+
const navbarBottomRect = this.$.navbarBottom.getBoundingClientRect();
203+
204+
const isDrawerAnimating = this.__isDrawerAnimating;
205+
206+
this.__resizeRaf = requestAnimationFrame(() => {
207+
if (isHostResized) {
208+
this._blockAnimationUntilAfterNextRender();
209+
this.__setOverlayMode(overlayMode);
210+
}
211+
212+
if (isHostResized || isNavbarResized) {
213+
this.__setTouchOptimized(touchOptimized);
214+
}
215+
216+
if (!isDrawerAnimating) {
217+
this.__setOffsetSize({
218+
drawerRect,
219+
navbarTopRect,
220+
navbarBottomRect,
221+
});
222+
}
223+
});
224+
}
225+
220226
/**
221227
* A callback for the `primarySection` property observer.
222228
*
@@ -306,48 +312,27 @@ export const AppLayoutMixin = (superclass) =>
306312
}
307313
}
308314

309-
/** @protected */
310-
_updateDrawerSize() {
315+
/** @private */
316+
__updateDrawerSize() {
311317
const childCount = this.querySelectorAll('[slot=drawer]').length;
312-
const drawer = this.$.drawer;
313-
314318
if (childCount === 0) {
315-
drawer.setAttribute('hidden', '');
319+
this.$.drawer.setAttribute('hidden', '');
316320
this.style.setProperty('--_vaadin-app-layout-drawer-width', 0);
317321
} else {
318-
drawer.removeAttribute('hidden');
322+
this.$.drawer.removeAttribute('hidden');
319323
this.style.removeProperty('--_vaadin-app-layout-drawer-width');
320324
}
321-
this._updateOffsetSize();
322325
}
323326

324327
/** @private */
325-
_resize() {
326-
this._blockAnimationUntilAfterNextRender();
327-
this._updateTouchOptimizedMode();
328-
this._updateOverlayMode();
329-
}
330-
331-
/** @protected */
332-
_updateOffsetSize() {
333-
const navbar = this.$.navbarTop;
334-
const navbarRect = navbar.getBoundingClientRect();
335-
336-
const navbarBottom = this.$.navbarBottom;
337-
const navbarBottomRect = navbarBottom.getBoundingClientRect();
338-
339-
const drawer = this.$.drawer;
340-
const drawerRect = drawer.getBoundingClientRect();
341-
342-
this.style.setProperty('--_vaadin-app-layout-navbar-offset-size', `${navbarRect.height}px`);
343-
this.style.setProperty('--_vaadin-app-layout-navbar-offset-size-bottom', `${navbarBottomRect.height}px`);
328+
__setOffsetSize({ drawerRect, navbarTopRect, navbarBottomRect }) {
344329
this.style.setProperty('--_vaadin-app-layout-drawer-offset-size', `${drawerRect.width}px`);
330+
this.style.setProperty('--_vaadin-app-layout-navbar-offset-size', `${navbarTopRect.height}px`);
331+
this.style.setProperty('--_vaadin-app-layout-navbar-offset-size-bottom', `${navbarBottomRect.height}px`);
345332
}
346333

347-
/** @protected */
348-
_updateOverlayMode() {
349-
const overlay = this._getCustomPropertyValue('--vaadin-app-layout-drawer-overlay') === 'true';
350-
334+
/** @private */
335+
__setOverlayMode(overlay) {
351336
if (!this.overlay && overlay) {
352337
// Changed from not overlay to overlay
353338
this._drawerStateSaved = this.drawerOpened;
@@ -487,12 +472,9 @@ export const AppLayoutMixin = (superclass) =>
487472
return (customPropertyValue || '').trim().toLowerCase();
488473
}
489474

490-
/** @protected */
491-
_updateTouchOptimizedMode() {
492-
const touchOptimized = this._getCustomPropertyValue('--vaadin-app-layout-touch-optimized') === 'true';
493-
475+
/** @private */
476+
__setTouchOptimized(touchOptimized) {
494477
const navbarItems = this.querySelectorAll('[slot*="navbar"]');
495-
496478
if (navbarItems.length > 0) {
497479
Array.from(navbarItems).forEach((navbar) => {
498480
if (navbar.getAttribute('slot').indexOf('touch-optimized') > -1) {
@@ -518,8 +500,6 @@ export const AppLayoutMixin = (superclass) =>
518500
} else {
519501
this.$.navbarBottom.removeAttribute('hidden');
520502
}
521-
522-
this._updateOffsetSize();
523503
}
524504

525505
/** @protected */
@@ -533,6 +513,18 @@ export const AppLayoutMixin = (superclass) =>
533513
});
534514
}
535515

516+
/**
517+
* Forces the ResizeObserver to re-report the size of the given element,
518+
* scheduling a new {@link __onResize} callback even if the size hasn't changed.
519+
*
520+
* @param {Element} element
521+
* @private
522+
*/
523+
__scheduleResize(element) {
524+
this.__resizeObserver.unobserve(element);
525+
this.__resizeObserver.observe(element);
526+
}
527+
536528
/**
537529
* App Layout listens to `close-overlay-drawer` on the window level.
538530
* A custom event can be dispatched and the App Layout will close the drawer in overlay.

0 commit comments

Comments
 (0)