Skip to content

Commit c7ca3b7

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

File tree

2 files changed

+166
-128
lines changed

2 files changed

+166
-128
lines changed

src/cdk/table/sticky-styler.ts

Lines changed: 161 additions & 120 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
@@ -64,6 +66,7 @@ export class StickyStyler {
6466
private _isBrowser = true,
6567
private readonly _needsPositionStickyOnElement = true,
6668
private readonly _positionListener?: StickyPositioningListener,
69+
private readonly _tableInjector?: Injector,
6770
) {
6871
this._borderCellCss = {
6972
'top': `${_stickCellCss}-border-elem-top`,
@@ -92,18 +95,20 @@ export class StickyStyler {
9295
continue;
9396
}
9497

95-
elementsToClear.push(row);
96-
for (let i = 0; i < row.children.length; i++) {
97-
elementsToClear.push(row.children[i] as HTMLElement);
98-
}
98+
elementsToClear.push(row, ...(row.children as HTMLCollectionOf<HTMLElement>));
9999
}
100100

101101
// 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-
}
106-
});
102+
afterNextRender(
103+
{
104+
write: () => {
105+
for (const element of elementsToClear) {
106+
this._removeStickyStyle(element, stickyDirections);
107+
}
108+
},
109+
},
110+
{injector: this._tableInjector},
111+
);
107112
}
108113

109114
/**
@@ -147,54 +152,67 @@ export class StickyStyler {
147152
}
148153

149154
// 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);
155+
const firstRow = rows[0];
156+
const numCells = firstRow.children.length;
157+
158+
const isRtl = this.direction === 'rtl';
159+
const start = isRtl ? 'right' : 'left';
160+
const end = isRtl ? 'left' : 'right';
161+
162+
const lastStickyStart = stickyStartStates.lastIndexOf(true);
163+
const firstStickyEnd = stickyEndStates.indexOf(true);
164+
165+
let cellWidths: number[];
166+
let startPositions: number[];
167+
let endPositions: number[];
168+
169+
afterNextRender(
170+
{
171+
earlyRead: () => {
172+
cellWidths = this._getCellWidths(firstRow, recalculateCellWidths);
173+
174+
startPositions = this._getStickyStartColumnPositions(cellWidths, stickyStartStates);
175+
endPositions = this._getStickyEndColumnPositions(cellWidths, stickyEndStates);
176+
},
177+
write: () => {
178+
for (const row of rows) {
179+
for (let i = 0; i < numCells; i++) {
180+
const cell = row.children[i] as HTMLElement;
181+
if (stickyStartStates[i]) {
182+
this._addStickyStyle(cell, start, startPositions[i], i === lastStickyStart);
183+
}
184+
185+
if (stickyEndStates[i]) {
186+
this._addStickyStyle(cell, end, endPositions[i], i === firstStickyEnd);
187+
}
188+
}
170189
}
171190

172-
if (stickyEndStates[i]) {
173-
this._addStickyStyle(cell, end, endPositions[i], i === firstStickyEnd);
191+
if (this._positionListener) {
192+
this._positionListener.stickyColumnsUpdated({
193+
sizes:
194+
lastStickyStart === -1
195+
? []
196+
: cellWidths
197+
.slice(0, lastStickyStart + 1)
198+
.map((width, index) => (stickyStartStates[index] ? width : null)),
199+
});
200+
this._positionListener.stickyEndColumnsUpdated({
201+
sizes:
202+
firstStickyEnd === -1
203+
? []
204+
: cellWidths
205+
.slice(firstStickyEnd)
206+
.map((width, index) =>
207+
stickyEndStates[index + firstStickyEnd] ? width : null,
208+
)
209+
.reverse(),
210+
});
174211
}
175-
}
176-
}
177-
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-
}
197-
});
212+
},
213+
},
214+
{injector: this._tableInjector},
215+
);
198216
}
199217

200218
/**
@@ -214,64 +232,70 @@ export class StickyStyler {
214232
return;
215233
}
216234

217-
// Coalesce with other sticky row updates (top/bottom), sticky columns updates
218-
// (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-
}
235+
// If positioning the rows to the bottom, reverse their order when evaluating the sticky
236+
// position such that the last row stuck will be "bottom: 0px" and so on. Note that the
237+
// sticky states need to be reversed as well.
238+
const rows = position === 'bottom' ? rowsToStick.slice().reverse() : rowsToStick;
239+
const states = position === 'bottom' ? stickyStates.slice().reverse() : stickyStates;
235240

236-
stickyOffsets[rowIndex] = stickyOffset;
237-
const row = rows[rowIndex];
238-
elementsToStick[rowIndex] = this._isNativeHtmlTable
239-
? (Array.from(row.children) as HTMLElement[])
240-
: [row];
241-
242-
const height = this._retrieveElementSize(row).height;
243-
stickyOffset += height;
244-
stickyCellHeights[rowIndex] = height;
245-
}
241+
// Measure row heights all at once before adding sticky styles to reduce layout thrashing.
242+
const stickyOffsets: number[] = [];
243+
const stickyCellHeights: (number | undefined)[] = [];
244+
const elementsToStick: HTMLElement[][] = [];
246245

247-
const borderedRowIndex = states.lastIndexOf(true);
248-
249-
for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
250-
if (!states[rowIndex]) {
251-
continue;
252-
}
253-
254-
const offset = stickyOffsets[rowIndex];
255-
const isBorderedRowIndex = rowIndex === borderedRowIndex;
256-
for (const element of elementsToStick[rowIndex]) {
257-
this._addStickyStyle(element, position, offset, isBorderedRowIndex);
258-
}
259-
}
246+
// Coalesce with other sticky row updates (top/bottom), sticky columns updates
247+
// (and potentially other changes like column resize).
248+
afterNextRender(
249+
{
250+
earlyRead: () => {
251+
for (let rowIndex = 0, stickyOffset = 0; rowIndex < rows.length; rowIndex++) {
252+
if (!states[rowIndex]) {
253+
continue;
254+
}
255+
256+
stickyOffsets[rowIndex] = stickyOffset;
257+
const row = rows[rowIndex];
258+
elementsToStick[rowIndex] = this._isNativeHtmlTable
259+
? (Array.from(row.children) as HTMLElement[])
260+
: [row];
261+
262+
const height = this._retrieveElementSize(row).height;
263+
stickyOffset += height;
264+
stickyCellHeights[rowIndex] = height;
265+
}
266+
},
267+
write: () => {
268+
const borderedRowIndex = states.lastIndexOf(true);
269+
270+
for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
271+
if (!states[rowIndex]) {
272+
continue;
273+
}
274+
275+
const offset = stickyOffsets[rowIndex];
276+
const isBorderedRowIndex = rowIndex === borderedRowIndex;
277+
for (const element of elementsToStick[rowIndex]) {
278+
this._addStickyStyle(element, position, offset, isBorderedRowIndex);
279+
}
280+
}
260281

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-
}
274-
});
282+
if (position === 'top') {
283+
this._positionListener?.stickyHeaderRowsUpdated({
284+
sizes: stickyCellHeights,
285+
offsets: stickyOffsets,
286+
elements: elementsToStick,
287+
});
288+
} else {
289+
this._positionListener?.stickyFooterRowsUpdated({
290+
sizes: stickyCellHeights,
291+
offsets: stickyOffsets,
292+
elements: elementsToStick,
293+
});
294+
}
295+
},
296+
},
297+
{injector: this._tableInjector},
298+
);
275299
}
276300

277301
/**
@@ -286,17 +310,30 @@ export class StickyStyler {
286310
}
287311

288312
// 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);
297-
}
298-
}
299-
});
313+
afterNextRender(
314+
{
315+
write: () => {
316+
const tfoot = tableElement.querySelector('tfoot')!;
317+
318+
if (tfoot) {
319+
if (stickyStates.some(state => !state)) {
320+
this._removeStickyStyle(tfoot, ['bottom']);
321+
} else {
322+
this._addStickyStyle(tfoot, 'bottom', 0, false);
323+
}
324+
}
325+
},
326+
},
327+
{injector: this._tableInjector},
328+
);
329+
}
330+
331+
destroy() {
332+
if (this._stickyColumnsReplayTimeout) {
333+
clearTimeout(this._stickyColumnsReplayTimeout);
334+
}
335+
336+
this._destroyed = true;
300337
}
301338

302339
/**
@@ -516,6 +553,10 @@ export class StickyStyler {
516553
}
517554

518555
this._stickyColumnsReplayTimeout = setTimeout(() => {
556+
if (this._destroyed) {
557+
return;
558+
}
559+
519560
for (const update of this._updatedStickyColumnsParamsToReplay) {
520561
this.updateStickyColumns(
521562
update.rows,

src/cdk/table/table.ts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -654,6 +654,8 @@ export class CdkTable<T>
654654
}
655655

656656
ngOnDestroy() {
657+
this._stickyStyler?.destroy();
658+
657659
[
658660
this._rowOutlet?.viewContainer,
659661
this._headerRowOutlet?.viewContainer,
@@ -727,14 +729,8 @@ export class CdkTable<T>
727729

728730
this._updateNoDataRow();
729731

730-
afterNextRender(
731-
() => {
732-
this.updateStickyColumnStyles();
733-
},
734-
{injector: this._injector},
735-
);
736-
737732
this.contentChanged.next();
733+
this.updateStickyColumnStyles();
738734
}
739735

740736
/** Adds a column definition that was not included as part of the content children. */
@@ -1201,7 +1197,7 @@ export class CdkTable<T>
12011197

12021198
/** Adds the sticky column styles for the rows according to the columns' stick states. */
12031199
private _addStickyColumnStyles(rows: HTMLElement[], rowDef: BaseRowDef) {
1204-
const columnDefs = Array.from(rowDef.columns || []).map(columnName => {
1200+
const columnDefs = Array.from(rowDef?.columns || []).map(columnName => {
12051201
const columnDef = this._columnDefsByName.get(columnName);
12061202
if (!columnDef && (typeof ngDevMode === 'undefined' || ngDevMode)) {
12071203
throw getTableUnknownColumnError(columnName);
@@ -1396,6 +1392,7 @@ export class CdkTable<T>
13961392
this._platform.isBrowser,
13971393
this.needsPositionStickyOnElement,
13981394
this._stickyPositioningListener,
1395+
this._injector,
13991396
);
14001397
(this._dir ? this._dir.change : observableOf<Direction>())
14011398
.pipe(takeUntil(this._onDestroy))

0 commit comments

Comments
 (0)