Skip to content

Commit fbd6226

Browse files
authored
refactor: avoid forced reflows during OverflowController initialization (#11400)
## Summary This PR refactors OverflowController to separate DOM reads and writes: reads happen in the ResizeObserver callback, while writes are deferred to a requestAnimationFrame callback. This avoids forced reflows when multiple components or different parts of the same component are reading and writing the DOM during initialization. Grid initial render with Aura: | Before | After | |:-------:|:-------:| | ![Screenshot 2026-03-23 at 11 55 32](https://github.com/user-attachments/assets/500fdf83-0272-426a-b7c1-7e94cf551e92) | ![Screenshot 2026-03-23 at 11 55 44](https://github.com/user-attachments/assets/26416487-5b30-4e23-8ee6-0ec529e7e421) | | Rendering: ~333 ms | Rendering: ~304 ms |
1 parent 1b6c09f commit fbd6226

5 files changed

Lines changed: 40 additions & 20 deletions

File tree

packages/component-base/src/overflow-controller.js

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@
33
* Copyright (c) 2021 - 2026 Vaadin Ltd.
44
* This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
55
*/
6-
import { animationFrame } from './async.js';
7-
import { Debouncer } from './debounce.js';
86

97
/**
108
* A controller that detects if content inside the element overflows its scrolling viewport,
@@ -48,12 +46,7 @@ export class OverflowController {
4846
observe() {
4947
const { host } = this;
5048

51-
this.__resizeObserver = new ResizeObserver(() => {
52-
this.__debounceOverflow = Debouncer.debounce(this.__debounceOverflow, animationFrame, () => {
53-
this.__updateOverflow();
54-
});
55-
});
56-
49+
this.__resizeObserver = new ResizeObserver(() => this.__onResize());
5750
this.__resizeObserver.observe(host);
5851

5952
// Observe initial children
@@ -74,26 +67,43 @@ export class OverflowController {
7467
this.__resizeObserver.unobserve(node);
7568
}
7669
});
77-
});
7870

79-
this.__updateOverflow();
71+
if (addedNodes.length === 0 && removedNodes.length > 0) {
72+
this.__updateState({ sync: true });
73+
}
74+
});
8075
});
8176

8277
this.__childObserver.observe(host, { childList: true });
8378

8479
// Update overflow attribute on scroll
8580
this.scrollTarget.addEventListener('scroll', this.__boundOnScroll);
81+
}
8682

87-
this.__updateOverflow();
83+
/** @private */
84+
__onResize() {
85+
this.__updateState({ sync: false });
8886
}
8987

9088
/** @private */
9189
__onScroll() {
92-
this.__updateOverflow();
90+
this.__updateState({ sync: true });
9391
}
9492

9593
/** @private */
96-
__updateOverflow() {
94+
__updateState({ sync }) {
95+
cancelAnimationFrame(this.__resizeRaf);
96+
97+
const state = this.__readState();
98+
if (sync) {
99+
this.__writeState(state);
100+
} else {
101+
this.__resizeRaf = requestAnimationFrame(() => this.__writeState(state));
102+
}
103+
}
104+
105+
/** @private */
106+
__readState() {
97107
const target = this.scrollTarget;
98108

99109
let overflow = '';
@@ -115,10 +125,14 @@ export class OverflowController {
115125
overflow += ' end';
116126
}
117127

118-
overflow = overflow.trim();
119-
if (overflow.length > 0 && this.host.getAttribute('overflow') !== overflow) {
128+
return { overflow: overflow.trim() };
129+
}
130+
131+
/** @private */
132+
__writeState({ overflow }) {
133+
if (overflow) {
120134
this.host.setAttribute('overflow', overflow);
121-
} else if (overflow.length === 0 && this.host.hasAttribute('overflow')) {
135+
} else {
122136
this.host.removeAttribute('overflow');
123137
}
124138
}

packages/grid/test/scrolling-mode.test.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { flushGrid, infiniteDataProvider, scrollToEnd } from './helpers.js';
77
describe('scrolling mode', () => {
88
let grid;
99

10-
beforeEach(() => {
10+
beforeEach(async () => {
1111
grid = fixtureSync(`
1212
<vaadin-grid style="width: 50px; height: 400px;" size="1000">
1313
<vaadin-grid-column></vaadin-grid-column>
@@ -18,6 +18,7 @@ describe('scrolling mode', () => {
1818
};
1919
grid.dataProvider = infiniteDataProvider;
2020
flushGrid(grid);
21+
await nextFrame();
2122
});
2223

2324
it('should not throw on table wheel', () => {

packages/scroller/test/scroller.test.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { expect } from '@vaadin/chai-plugins';
22
import { sendKeys } from '@vaadin/test-runner-commands';
3-
import { fixtureSync, nextFrame, nextRender, nextUpdate } from '@vaadin/testing-helpers';
3+
import { fixtureSync, nextFrame, nextRender, nextResize, nextUpdate } from '@vaadin/testing-helpers';
44
import '../src/vaadin-scroller.js';
55

66
describe('vaadin-scroller', () => {
@@ -98,6 +98,7 @@ describe('vaadin-scroller', () => {
9898
scroller.appendChild(div);
9999

100100
await nextRender();
101+
await nextResize(scroller);
101102
});
102103

103104
it('should set overflow attribute to "end" when scroll is at the beginning', () => {
@@ -128,6 +129,7 @@ describe('vaadin-scroller', () => {
128129
div.innerHTML = '<div style="font-size: 1.25em;">Long<br>text<br>that<br>has<br>many<br>lines</div>';
129130
scroller.appendChild(div);
130131

132+
await nextResize(scroller);
131133
await nextRender();
132134
});
133135

packages/tabsheet/test/tabsheet.test.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect } from '@vaadin/chai-plugins';
2-
import { aTimeout, fixtureSync, nextFrame } from '@vaadin/testing-helpers';
2+
import { aTimeout, fixtureSync, nextFrame, nextResize } from '@vaadin/testing-helpers';
33
import sinon from 'sinon';
44
import '../src/vaadin-tabsheet.js';
55

@@ -306,6 +306,7 @@ describe('tabsheet', () => {
306306
tabsheet.style.maxHeight = `${tabsheet.offsetHeight - 10}px`;
307307
scrollTarget = tabsheet.shadowRoot.querySelector('[part~="content"]');
308308

309+
await nextResize(tabsheet);
309310
await nextFrame();
310311
});
311312

packages/virtual-list/test/virtual-list.test.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { expect } from '@vaadin/chai-plugins';
2-
import { fixtureSync, nextFrame } from '@vaadin/testing-helpers';
2+
import { fixtureSync, nextFrame, nextResize } from '@vaadin/testing-helpers';
33
import '../src/vaadin-virtual-list.js';
44

55
describe('virtual-list', () => {
66
let list;
77

88
beforeEach(async () => {
99
list = fixtureSync(`<vaadin-virtual-list></vaadin-virtual-list>`);
10+
await nextResize(list);
1011
await nextFrame();
1112
});
1213

@@ -54,6 +55,7 @@ describe('virtual-list', () => {
5455
el.textContent = model.item.value;
5556
};
5657

58+
await nextResize(list);
5759
await nextFrame();
5860
});
5961

0 commit comments

Comments
 (0)