Skip to content

Commit d721f0c

Browse files
authored
perf(cdk/table): Use afterNextRender for sticky styling. Fixes a performance regression dating back to #28393 and removes need for coalesced sticky styler. (#30242)
1 parent 7b31793 commit d721f0c

File tree

3 files changed

+161
-119
lines changed

3 files changed

+161
-119
lines changed

src/cdk/table/sticky-styler.ts

Lines changed: 153 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
* Directions that can be used when setting sticky positioning.
1111
* @docs-private
1212
*/
13+
import {afterNextRender, Injector} from '@angular/core';
1314
import {Direction} from '@angular/cdk/bidi';
1415
import {_CoalescedStyleScheduler} from './coalesced-style-scheduler';
1516
import {StickyPositioningListener} from './sticky-position-listener';
@@ -41,6 +42,7 @@ export class StickyStyler {
4142
private _stickyColumnsReplayTimeout: number | null = null;
4243
private _cachedCellWidths: number[] = [];
4344
private readonly _borderCellCss: Readonly<{[d in StickyDirection]: string}>;
45+
private _destroyed = false;
4446

4547
/**
4648
* @param _isNativeHtmlTable Whether the sticky logic should be based on a table
@@ -55,6 +57,7 @@ export class StickyStyler {
5557
* the component stylesheet for _stickCellCss.
5658
* @param _positionListener A listener that is notified of changes to sticky rows/columns
5759
* and their dimensions.
60+
* @param _tableInjector The table's Injector.
5861
*/
5962
constructor(
6063
private _isNativeHtmlTable: boolean,
@@ -64,6 +67,7 @@ export class StickyStyler {
6467
private _isBrowser = true,
6568
private readonly _needsPositionStickyOnElement = true,
6669
private readonly _positionListener?: StickyPositioningListener,
70+
private readonly _tableInjector?: Injector,
6771
) {
6872
this._borderCellCss = {
6973
'top': `${_stickCellCss}-border-elem-top`,
@@ -92,17 +96,16 @@ export class StickyStyler {
9296
continue;
9397
}
9498

95-
elementsToClear.push(row);
96-
for (let i = 0; i < row.children.length; i++) {
97-
elementsToClear.push(row.children[i] as HTMLElement);
98-
}
99+
elementsToClear.push(row, ...(Array.from(row.children) as HTMLElement[]));
99100
}
100101

101102
// Coalesce with sticky row/column updates (and potentially other changes like column resize).
102-
this._coalescedStyleScheduler.schedule(() => {
103-
for (const element of elementsToClear) {
104-
this._removeStickyStyle(element, stickyDirections);
105-
}
103+
this._afterNextRender({
104+
write: () => {
105+
for (const element of elementsToClear) {
106+
this._removeStickyStyle(element, stickyDirections);
107+
}
108+
},
106109
});
107110
}
108111

@@ -147,53 +150,61 @@ export class StickyStyler {
147150
}
148151

149152
// Coalesce with sticky row updates (and potentially other changes like column resize).
150-
this._coalescedStyleScheduler.schedule(() => {
151-
const firstRow = rows[0];
152-
const numCells = firstRow.children.length;
153-
const cellWidths: number[] = this._getCellWidths(firstRow, recalculateCellWidths);
154-
155-
const startPositions = this._getStickyStartColumnPositions(cellWidths, stickyStartStates);
156-
const endPositions = this._getStickyEndColumnPositions(cellWidths, stickyEndStates);
157-
158-
const lastStickyStart = stickyStartStates.lastIndexOf(true);
159-
const firstStickyEnd = stickyEndStates.indexOf(true);
160-
161-
const isRtl = this.direction === 'rtl';
162-
const start = isRtl ? 'right' : 'left';
163-
const end = isRtl ? 'left' : 'right';
164-
165-
for (const row of rows) {
166-
for (let i = 0; i < numCells; i++) {
167-
const cell = row.children[i] as HTMLElement;
168-
if (stickyStartStates[i]) {
169-
this._addStickyStyle(cell, start, startPositions[i], i === lastStickyStart);
170-
}
171-
172-
if (stickyEndStates[i]) {
173-
this._addStickyStyle(cell, end, endPositions[i], i === firstStickyEnd);
153+
const firstRow = rows[0];
154+
const numCells = firstRow.children.length;
155+
156+
const isRtl = this.direction === 'rtl';
157+
const start = isRtl ? 'right' : 'left';
158+
const end = isRtl ? 'left' : 'right';
159+
160+
const lastStickyStart = stickyStartStates.lastIndexOf(true);
161+
const firstStickyEnd = stickyEndStates.indexOf(true);
162+
163+
let cellWidths: number[];
164+
let startPositions: number[];
165+
let endPositions: number[];
166+
167+
this._afterNextRender({
168+
earlyRead: () => {
169+
cellWidths = this._getCellWidths(firstRow, recalculateCellWidths);
170+
171+
startPositions = this._getStickyStartColumnPositions(cellWidths, stickyStartStates);
172+
endPositions = this._getStickyEndColumnPositions(cellWidths, stickyEndStates);
173+
},
174+
write: () => {
175+
for (const row of rows) {
176+
for (let i = 0; i < numCells; i++) {
177+
const cell = row.children[i] as HTMLElement;
178+
if (stickyStartStates[i]) {
179+
this._addStickyStyle(cell, start, startPositions[i], i === lastStickyStart);
180+
}
181+
182+
if (stickyEndStates[i]) {
183+
this._addStickyStyle(cell, end, endPositions[i], i === firstStickyEnd);
184+
}
174185
}
175186
}
176-
}
177187

178-
if (this._positionListener) {
179-
this._positionListener.stickyColumnsUpdated({
180-
sizes:
181-
lastStickyStart === -1
182-
? []
183-
: cellWidths
184-
.slice(0, lastStickyStart + 1)
185-
.map((width, index) => (stickyStartStates[index] ? width : null)),
186-
});
187-
this._positionListener.stickyEndColumnsUpdated({
188-
sizes:
189-
firstStickyEnd === -1
190-
? []
191-
: cellWidths
192-
.slice(firstStickyEnd)
193-
.map((width, index) => (stickyEndStates[index + firstStickyEnd] ? width : null))
194-
.reverse(),
195-
});
196-
}
188+
if (this._positionListener) {
189+
this._positionListener.stickyColumnsUpdated({
190+
sizes:
191+
lastStickyStart === -1
192+
? []
193+
: cellWidths
194+
.slice(0, lastStickyStart + 1)
195+
.map((width, index) => (stickyStartStates[index] ? width : null)),
196+
});
197+
this._positionListener.stickyEndColumnsUpdated({
198+
sizes:
199+
firstStickyEnd === -1
200+
? []
201+
: cellWidths
202+
.slice(firstStickyEnd)
203+
.map((width, index) => (stickyEndStates[index + firstStickyEnd] ? width : null))
204+
.reverse(),
205+
});
206+
}
207+
},
197208
});
198209
}
199210

@@ -214,63 +225,66 @@ export class StickyStyler {
214225
return;
215226
}
216227

228+
// If positioning the rows to the bottom, reverse their order when evaluating the sticky
229+
// position such that the last row stuck will be "bottom: 0px" and so on. Note that the
230+
// sticky states need to be reversed as well.
231+
const rows = position === 'bottom' ? rowsToStick.slice().reverse() : rowsToStick;
232+
const states = position === 'bottom' ? stickyStates.slice().reverse() : stickyStates;
233+
234+
// Measure row heights all at once before adding sticky styles to reduce layout thrashing.
235+
const stickyOffsets: number[] = [];
236+
const stickyCellHeights: (number | undefined)[] = [];
237+
const elementsToStick: HTMLElement[][] = [];
238+
217239
// Coalesce with other sticky row updates (top/bottom), sticky columns updates
218240
// (and potentially other changes like column resize).
219-
this._coalescedStyleScheduler.schedule(() => {
220-
// If positioning the rows to the bottom, reverse their order when evaluating the sticky
221-
// position such that the last row stuck will be "bottom: 0px" and so on. Note that the
222-
// sticky states need to be reversed as well.
223-
const rows = position === 'bottom' ? rowsToStick.slice().reverse() : rowsToStick;
224-
const states = position === 'bottom' ? stickyStates.slice().reverse() : stickyStates;
225-
226-
// Measure row heights all at once before adding sticky styles to reduce layout thrashing.
227-
const stickyOffsets: number[] = [];
228-
const stickyCellHeights: (number | undefined)[] = [];
229-
const elementsToStick: HTMLElement[][] = [];
230-
231-
for (let rowIndex = 0, stickyOffset = 0; rowIndex < rows.length; rowIndex++) {
232-
if (!states[rowIndex]) {
233-
continue;
234-
}
241+
this._afterNextRender({
242+
earlyRead: () => {
243+
for (let rowIndex = 0, stickyOffset = 0; rowIndex < rows.length; rowIndex++) {
244+
if (!states[rowIndex]) {
245+
continue;
246+
}
235247

236-
stickyOffsets[rowIndex] = stickyOffset;
237-
const row = rows[rowIndex];
238-
elementsToStick[rowIndex] = this._isNativeHtmlTable
239-
? (Array.from(row.children) as HTMLElement[])
240-
: [row];
248+
stickyOffsets[rowIndex] = stickyOffset;
249+
const row = rows[rowIndex];
250+
elementsToStick[rowIndex] = this._isNativeHtmlTable
251+
? (Array.from(row.children) as HTMLElement[])
252+
: [row];
241253

242-
const height = this._retrieveElementSize(row).height;
243-
stickyOffset += height;
244-
stickyCellHeights[rowIndex] = height;
245-
}
254+
const height = this._retrieveElementSize(row).height;
255+
stickyOffset += height;
256+
stickyCellHeights[rowIndex] = height;
257+
}
258+
},
259+
write: () => {
260+
const borderedRowIndex = states.lastIndexOf(true);
246261

247-
const borderedRowIndex = states.lastIndexOf(true);
262+
for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
263+
if (!states[rowIndex]) {
264+
continue;
265+
}
248266

249-
for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
250-
if (!states[rowIndex]) {
251-
continue;
267+
const offset = stickyOffsets[rowIndex];
268+
const isBorderedRowIndex = rowIndex === borderedRowIndex;
269+
for (const element of elementsToStick[rowIndex]) {
270+
this._addStickyStyle(element, position, offset, isBorderedRowIndex);
271+
}
252272
}
253273

254-
const offset = stickyOffsets[rowIndex];
255-
const isBorderedRowIndex = rowIndex === borderedRowIndex;
256-
for (const element of elementsToStick[rowIndex]) {
257-
this._addStickyStyle(element, position, offset, isBorderedRowIndex);
274+
if (position === 'top') {
275+
this._positionListener?.stickyHeaderRowsUpdated({
276+
sizes: stickyCellHeights,
277+
offsets: stickyOffsets,
278+
elements: elementsToStick,
279+
});
280+
} else {
281+
this._positionListener?.stickyFooterRowsUpdated({
282+
sizes: stickyCellHeights,
283+
offsets: stickyOffsets,
284+
elements: elementsToStick,
285+
});
258286
}
259-
}
260-
261-
if (position === 'top') {
262-
this._positionListener?.stickyHeaderRowsUpdated({
263-
sizes: stickyCellHeights,
264-
offsets: stickyOffsets,
265-
elements: elementsToStick,
266-
});
267-
} else {
268-
this._positionListener?.stickyFooterRowsUpdated({
269-
sizes: stickyCellHeights,
270-
offsets: stickyOffsets,
271-
elements: elementsToStick,
272-
});
273-
}
287+
},
274288
});
275289
}
276290

@@ -286,19 +300,30 @@ export class StickyStyler {
286300
}
287301

288302
// Coalesce with other sticky updates (and potentially other changes like column resize).
289-
this._coalescedStyleScheduler.schedule(() => {
290-
const tfoot = tableElement.querySelector('tfoot')!;
291-
292-
if (tfoot) {
293-
if (stickyStates.some(state => !state)) {
294-
this._removeStickyStyle(tfoot, ['bottom']);
295-
} else {
296-
this._addStickyStyle(tfoot, 'bottom', 0, false);
303+
this._afterNextRender({
304+
write: () => {
305+
const tfoot = tableElement.querySelector('tfoot')!;
306+
307+
if (tfoot) {
308+
if (stickyStates.some(state => !state)) {
309+
this._removeStickyStyle(tfoot, ['bottom']);
310+
} else {
311+
this._addStickyStyle(tfoot, 'bottom', 0, false);
312+
}
297313
}
298-
}
314+
},
299315
});
300316
}
301317

318+
/** Triggered by the table's OnDestroy hook. */
319+
destroy() {
320+
if (this._stickyColumnsReplayTimeout) {
321+
clearTimeout(this._stickyColumnsReplayTimeout);
322+
}
323+
324+
this._destroyed = true;
325+
}
326+
302327
/**
303328
* Removes the sticky style on the element by removing the sticky cell CSS class, re-evaluating
304329
* the zIndex, removing each of the provided sticky directions, and removing the
@@ -516,6 +541,10 @@ export class StickyStyler {
516541
}
517542

518543
this._stickyColumnsReplayTimeout = setTimeout(() => {
544+
if (this._destroyed) {
545+
return;
546+
}
547+
519548
for (const update of this._updatedStickyColumnsParamsToReplay) {
520549
this.updateStickyColumns(
521550
update.rows,
@@ -530,6 +559,21 @@ export class StickyStyler {
530559
}, 0);
531560
}
532561
}
562+
563+
/**
564+
* Invoke afterNextRender with the table's injector, falling back to CoalescedStyleScheduler
565+
* if the injector was not provided.
566+
*/
567+
private _afterNextRender(spec: {earlyRead?: () => void; write: () => void}) {
568+
if (this._tableInjector) {
569+
afterNextRender(spec, {injector: this._tableInjector});
570+
} else {
571+
this._coalescedStyleScheduler.schedule(() => {
572+
spec.earlyRead?.();
573+
spec.write();
574+
});
575+
}
576+
}
533577
}
534578

535579
function isCell(element: Element) {

0 commit comments

Comments
 (0)