Skip to content

Commit a3449dc

Browse files
kseamoncrisbeto
authored andcommitted
perf(cdk/table): Use ResizeObservers instead of dom measurement to reduce layout thrashing (where possible) (#29814)
(cherry picked from commit 8c52b6d)
1 parent 197747e commit a3449dc

File tree

2 files changed

+129
-4
lines changed

2 files changed

+129
-4
lines changed

src/cdk/table/sticky-styler.ts

+128-3
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ import {StickyPositioningListener} from './sticky-position-listener';
1616

1717
export type StickyDirection = 'top' | 'bottom' | 'left' | 'right';
1818

19+
interface UpdateStickyColumnsParams {
20+
rows: HTMLElement[];
21+
stickyStartStates: boolean[];
22+
stickyEndStates: boolean[];
23+
}
24+
1925
/**
2026
* List of all possible directions that can be used for sticky positioning.
2127
* @docs-private
@@ -27,6 +33,12 @@ export const STICKY_DIRECTIONS: StickyDirection[] = ['top', 'bottom', 'left', 'r
2733
* @docs-private
2834
*/
2935
export class StickyStyler {
36+
private _elemSizeCache = new WeakMap<HTMLElement, {width: number; height: number}>();
37+
private _resizeObserver = globalThis?.ResizeObserver
38+
? new globalThis.ResizeObserver(entries => this._updateCachedSizes(entries))
39+
: null;
40+
private _updatedStickyColumnsParamsToReplay: UpdateStickyColumnsParams[] = [];
41+
private _stickyColumnsReplayTimeout: number | null = null;
3042
private _cachedCellWidths: number[] = [];
3143
private readonly _borderCellCss: Readonly<{[d in StickyDirection]: string}>;
3244

@@ -68,6 +80,10 @@ export class StickyStyler {
6880
* @param stickyDirections The directions that should no longer be set as sticky on the rows.
6981
*/
7082
clearStickyPositioning(rows: HTMLElement[], stickyDirections: StickyDirection[]) {
83+
if (stickyDirections.includes('left') || stickyDirections.includes('right')) {
84+
this._removeFromStickyColumnReplayQueue(rows);
85+
}
86+
7187
const elementsToClear: HTMLElement[] = [];
7288
for (const row of rows) {
7389
// If the row isn't an element (e.g. if it's an `ng-container`),
@@ -100,13 +116,23 @@ export class StickyStyler {
100116
* in this index position should be stuck to the end of the row.
101117
* @param recalculateCellWidths Whether the sticky styler should recalculate the width of each
102118
* column cell. If `false` cached widths will be used instead.
119+
* @param replay Whether to enqueue this call for replay after a ResizeObserver update.
103120
*/
104121
updateStickyColumns(
105122
rows: HTMLElement[],
106123
stickyStartStates: boolean[],
107124
stickyEndStates: boolean[],
108125
recalculateCellWidths = true,
126+
replay = true,
109127
) {
128+
if (replay) {
129+
this._updateStickyColumnReplayQueue({
130+
rows: [...rows],
131+
stickyStartStates: [...stickyStartStates],
132+
stickyEndStates: [...stickyEndStates],
133+
});
134+
}
135+
110136
if (
111137
!rows.length ||
112138
!this._isBrowser ||
@@ -213,7 +239,7 @@ export class StickyStyler {
213239
? (Array.from(row.children) as HTMLElement[])
214240
: [row];
215241

216-
const height = row.getBoundingClientRect().height;
242+
const height = this._retrieveElementSize(row).height;
217243
stickyOffset += height;
218244
stickyCellHeights[rowIndex] = height;
219245
}
@@ -366,8 +392,8 @@ export class StickyStyler {
366392
const cellWidths: number[] = [];
367393
const firstRowCells = row.children;
368394
for (let i = 0; i < firstRowCells.length; i++) {
369-
let cell: HTMLElement = firstRowCells[i] as HTMLElement;
370-
cellWidths.push(cell.getBoundingClientRect().width);
395+
const cell = firstRowCells[i] as HTMLElement;
396+
cellWidths.push(this._retrieveElementSize(cell).width);
371397
}
372398

373399
this._cachedCellWidths = cellWidths;
@@ -411,4 +437,103 @@ export class StickyStyler {
411437

412438
return positions;
413439
}
440+
441+
/**
442+
* Retreives the most recently observed size of the specified element from the cache, or
443+
* meaures it directly if not yet cached.
444+
*/
445+
private _retrieveElementSize(element: HTMLElement): {width: number; height: number} {
446+
const cachedSize = this._elemSizeCache.get(element);
447+
if (cachedSize) {
448+
return cachedSize;
449+
}
450+
451+
const clientRect = element.getBoundingClientRect();
452+
const size = {width: clientRect.width, height: clientRect.height};
453+
454+
if (!this._resizeObserver) {
455+
return size;
456+
}
457+
458+
this._elemSizeCache.set(element, size);
459+
this._resizeObserver.observe(element, {box: 'border-box'});
460+
return size;
461+
}
462+
463+
/**
464+
* Conditionally enqueue the requested sticky update and clear previously queued updates
465+
* for the same rows.
466+
*/
467+
private _updateStickyColumnReplayQueue(params: UpdateStickyColumnsParams) {
468+
this._removeFromStickyColumnReplayQueue(params.rows);
469+
470+
// No need to replay if a flush is pending.
471+
if (this._stickyColumnsReplayTimeout) {
472+
return;
473+
}
474+
475+
this._updatedStickyColumnsParamsToReplay.push(params);
476+
}
477+
478+
/** Remove updates for the specified rows from the queue. */
479+
private _removeFromStickyColumnReplayQueue(rows: HTMLElement[]) {
480+
const rowsSet = new Set(rows);
481+
for (const update of this._updatedStickyColumnsParamsToReplay) {
482+
update.rows = update.rows.filter(row => !rowsSet.has(row));
483+
}
484+
this._updatedStickyColumnsParamsToReplay = this._updatedStickyColumnsParamsToReplay.filter(
485+
update => !!update.rows.length,
486+
);
487+
}
488+
489+
/** Update _elemSizeCache with the observed sizes. */
490+
private _updateCachedSizes(entries: ResizeObserverEntry[]) {
491+
let needsColumnUpdate = false;
492+
for (const entry of entries) {
493+
const newEntry = entry.borderBoxSize?.length
494+
? {
495+
width: entry.borderBoxSize[0].inlineSize,
496+
height: entry.borderBoxSize[0].blockSize,
497+
}
498+
: {
499+
width: entry.contentRect.width,
500+
height: entry.contentRect.height,
501+
};
502+
503+
if (
504+
newEntry.width !== this._elemSizeCache.get(entry.target as HTMLElement)?.width &&
505+
isCell(entry.target)
506+
) {
507+
needsColumnUpdate = true;
508+
}
509+
510+
this._elemSizeCache.set(entry.target as HTMLElement, newEntry);
511+
}
512+
513+
if (needsColumnUpdate && this._updatedStickyColumnsParamsToReplay.length) {
514+
if (this._stickyColumnsReplayTimeout) {
515+
clearTimeout(this._stickyColumnsReplayTimeout);
516+
}
517+
518+
this._stickyColumnsReplayTimeout = setTimeout(() => {
519+
for (const update of this._updatedStickyColumnsParamsToReplay) {
520+
this.updateStickyColumns(
521+
update.rows,
522+
update.stickyStartStates,
523+
update.stickyEndStates,
524+
true,
525+
false,
526+
);
527+
}
528+
this._updatedStickyColumnsParamsToReplay = [];
529+
this._stickyColumnsReplayTimeout = null;
530+
}, 0);
531+
}
532+
}
533+
}
534+
535+
function isCell(element: Element) {
536+
return ['cdk-cell', 'cdk-header-cell', 'cdk-footer-cell'].some(klass =>
537+
element.classList.contains(klass),
538+
);
414539
}

tools/public_api_guard/cdk/table.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -573,7 +573,7 @@ export class StickyStyler {
573573
_getStickyStartColumnPositions(widths: number[], stickyStates: boolean[]): number[];
574574
_removeStickyStyle(element: HTMLElement, stickyDirections: StickyDirection[]): void;
575575
stickRows(rowsToStick: HTMLElement[], stickyStates: boolean[], position: 'top' | 'bottom'): void;
576-
updateStickyColumns(rows: HTMLElement[], stickyStartStates: boolean[], stickyEndStates: boolean[], recalculateCellWidths?: boolean): void;
576+
updateStickyColumns(rows: HTMLElement[], stickyStartStates: boolean[], stickyEndStates: boolean[], recalculateCellWidths?: boolean, replay?: boolean): void;
577577
updateStickyFooterContainer(tableElement: Element, stickyStates: boolean[]): void;
578578
}
579579

0 commit comments

Comments
 (0)