Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

perf(cdk/table): Use ResizeObservers instead of dom measurement to re… #29814

Merged
merged 1 commit into from
Oct 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 128 additions & 3 deletions src/cdk/table/sticky-styler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ import {StickyPositioningListener} from './sticky-position-listener';

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

interface UpdateStickyColumnsParams {
rows: HTMLElement[];
stickyStartStates: boolean[];
stickyEndStates: boolean[];
}

/**
* List of all possible directions that can be used for sticky positioning.
* @docs-private
Expand All @@ -27,6 +33,12 @@ export const STICKY_DIRECTIONS: StickyDirection[] = ['top', 'bottom', 'left', 'r
* @docs-private
*/
export class StickyStyler {
private _elemSizeCache = new WeakMap<HTMLElement, {width: number; height: number}>();
private _resizeObserver = globalThis?.ResizeObserver
? new globalThis.ResizeObserver(entries => this._updateCachedSizes(entries))
: null;
private _updatedStickyColumnsParamsToReplay: UpdateStickyColumnsParams[] = [];
private _stickyColumnsReplayTimeout: number | null = null;
private _cachedCellWidths: number[] = [];
private readonly _borderCellCss: Readonly<{[d in StickyDirection]: string}>;

Expand Down Expand Up @@ -68,6 +80,10 @@ export class StickyStyler {
* @param stickyDirections The directions that should no longer be set as sticky on the rows.
*/
clearStickyPositioning(rows: HTMLElement[], stickyDirections: StickyDirection[]) {
if (stickyDirections.includes('left') || stickyDirections.includes('right')) {
this._removeFromStickyColumnReplayQueue(rows);
}

const elementsToClear: HTMLElement[] = [];
for (const row of rows) {
// If the row isn't an element (e.g. if it's an `ng-container`),
Expand Down Expand Up @@ -100,13 +116,23 @@ export class StickyStyler {
* in this index position should be stuck to the end of the row.
* @param recalculateCellWidths Whether the sticky styler should recalculate the width of each
* column cell. If `false` cached widths will be used instead.
* @param replay Whether to enqueue this call for replay after a ResizeObserver update.
*/
updateStickyColumns(
rows: HTMLElement[],
stickyStartStates: boolean[],
stickyEndStates: boolean[],
recalculateCellWidths = true,
replay = true,
) {
if (replay) {
this._updateStickyColumnReplayQueue({
rows: [...rows],
stickyStartStates: [...stickyStartStates],
stickyEndStates: [...stickyEndStates],
});
}

if (
!rows.length ||
!this._isBrowser ||
Expand Down Expand Up @@ -213,7 +239,7 @@ export class StickyStyler {
? (Array.from(row.children) as HTMLElement[])
: [row];

const height = row.getBoundingClientRect().height;
const height = this._retrieveElementSize(row).height;
stickyOffset += height;
stickyCellHeights[rowIndex] = height;
}
Expand Down Expand Up @@ -366,8 +392,8 @@ export class StickyStyler {
const cellWidths: number[] = [];
const firstRowCells = row.children;
for (let i = 0; i < firstRowCells.length; i++) {
let cell: HTMLElement = firstRowCells[i] as HTMLElement;
cellWidths.push(cell.getBoundingClientRect().width);
const cell = firstRowCells[i] as HTMLElement;
cellWidths.push(this._retrieveElementSize(cell).width);
}

this._cachedCellWidths = cellWidths;
Expand Down Expand Up @@ -411,4 +437,103 @@ export class StickyStyler {

return positions;
}

/**
* Retreives the most recently observed size of the specified element from the cache, or
* meaures it directly if not yet cached.
*/
private _retrieveElementSize(element: HTMLElement): {width: number; height: number} {
const cachedSize = this._elemSizeCache.get(element);
if (cachedSize) {
return cachedSize;
}

const clientRect = element.getBoundingClientRect();
const size = {width: clientRect.width, height: clientRect.height};

if (!this._resizeObserver) {
return size;
}

this._elemSizeCache.set(element, size);
this._resizeObserver.observe(element, {box: 'border-box'});
return size;
}

/**
* Conditionally enqueue the requested sticky update and clear previously queued updates
* for the same rows.
*/
private _updateStickyColumnReplayQueue(params: UpdateStickyColumnsParams) {
this._removeFromStickyColumnReplayQueue(params.rows);

// No need to replay if a flush is pending.
if (this._stickyColumnsReplayTimeout) {
return;
}

this._updatedStickyColumnsParamsToReplay.push(params);
}

/** Remove updates for the specified rows from the queue. */
private _removeFromStickyColumnReplayQueue(rows: HTMLElement[]) {
const rowsSet = new Set(rows);
for (const update of this._updatedStickyColumnsParamsToReplay) {
update.rows = update.rows.filter(row => !rowsSet.has(row));
}
this._updatedStickyColumnsParamsToReplay = this._updatedStickyColumnsParamsToReplay.filter(
update => !!update.rows.length,
);
}

/** Update _elemSizeCache with the observed sizes. */
private _updateCachedSizes(entries: ResizeObserverEntry[]) {
let needsColumnUpdate = false;
for (const entry of entries) {
const newEntry = entry.borderBoxSize?.length
? {
width: entry.borderBoxSize[0].inlineSize,
height: entry.borderBoxSize[0].blockSize,
}
: {
width: entry.contentRect.width,
height: entry.contentRect.height,
};

if (
newEntry.width !== this._elemSizeCache.get(entry.target as HTMLElement)?.width &&
isCell(entry.target)
) {
needsColumnUpdate = true;
}

this._elemSizeCache.set(entry.target as HTMLElement, newEntry);
}

if (needsColumnUpdate && this._updatedStickyColumnsParamsToReplay.length) {
if (this._stickyColumnsReplayTimeout) {
clearTimeout(this._stickyColumnsReplayTimeout);
}

this._stickyColumnsReplayTimeout = setTimeout(() => {
for (const update of this._updatedStickyColumnsParamsToReplay) {
this.updateStickyColumns(
update.rows,
update.stickyStartStates,
update.stickyEndStates,
true,
false,
);
}
this._updatedStickyColumnsParamsToReplay = [];
this._stickyColumnsReplayTimeout = null;
}, 0);
}
}
}

function isCell(element: Element) {
return ['cdk-cell', 'cdk-header-cell', 'cdk-footer-cell'].some(klass =>
element.classList.contains(klass),
);
}
2 changes: 1 addition & 1 deletion tools/public_api_guard/cdk/table.md
Original file line number Diff line number Diff line change
Expand Up @@ -565,7 +565,7 @@ export class StickyStyler {
_getStickyStartColumnPositions(widths: number[], stickyStates: boolean[]): number[];
_removeStickyStyle(element: HTMLElement, stickyDirections: StickyDirection[]): void;
stickRows(rowsToStick: HTMLElement[], stickyStates: boolean[], position: 'top' | 'bottom'): void;
updateStickyColumns(rows: HTMLElement[], stickyStartStates: boolean[], stickyEndStates: boolean[], recalculateCellWidths?: boolean): void;
updateStickyColumns(rows: HTMLElement[], stickyStartStates: boolean[], stickyEndStates: boolean[], recalculateCellWidths?: boolean, replay?: boolean): void;
updateStickyFooterContainer(tableElement: Element, stickyStates: boolean[]): void;
}

Expand Down
Loading