Skip to content

Commit

Permalink
perf(cdk/table): Further defer direct dom measurement. In all cases I…
Browse files Browse the repository at this point in the history
…'ve observed, this fully eliminates layout thrashing from table init.
  • Loading branch information
kseamon committed Oct 31, 2024
1 parent 070be9f commit 4192088
Show file tree
Hide file tree
Showing 4 changed files with 161 additions and 61 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ describe('CdkTableScrollContainer', () => {
}

async function waitForLayout(): Promise<void> {
await new Promise(resolve => setTimeout(resolve));
await new Promise(resolve => setTimeout(resolve, 50));

// In newer versions of Chrome (change was noticed between 114 and 124), the computed
// style of `::-webkit-scrollbar-track` doesn't update until the styles of the container
Expand Down
157 changes: 123 additions & 34 deletions src/cdk/table/sticky-styler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ interface UpdateStickyColumnsParams {
stickyEndStates: boolean[];
}

interface UpdateStickRowsParams {
rowsToStick: HTMLElement[];
stickyStates: boolean[];
position: 'top' | 'bottom';
}

/**
* List of all possible directions that can be used for sticky positioning.
* @docs-private
Expand All @@ -38,7 +44,10 @@ export class StickyStyler {
? new globalThis.ResizeObserver(entries => this._updateCachedSizes(entries))
: null;
private _updatedStickyColumnsParamsToReplay: UpdateStickyColumnsParams[] = [];
private _stickyColumnsReplayTimeout: number | null = null;
private _updatedStickRowsParamsToReplay: UpdateStickRowsParams[] = [];
private _stickyReplayTimeout: number | null = null;
private _readSizeQueue: HTMLElement[] = [];
private _readSizeTimeout: number | null = null;
private _cachedCellWidths: number[] = [];
private readonly _borderCellCss: Readonly<{[d in StickyDirection]: string}>;

Expand Down Expand Up @@ -206,14 +215,27 @@ export class StickyStyler {
* should be stuck in the particular top or bottom position.
* @param position The position direction in which the row should be stuck if that row should be
* sticky.
*
* @param replay Whether to enqueue this call for replay after a ResizeObserver update.
*/
stickRows(rowsToStick: HTMLElement[], stickyStates: boolean[], position: 'top' | 'bottom') {
stickRows(
rowsToStick: HTMLElement[],
stickyStates: boolean[],
position: 'top' | 'bottom',
replay = true,
) {
// Since we can't measure the rows on the server, we can't stick the rows properly.
if (!this._isBrowser) {
return;
}

if (replay) {
this._updateStickRowsReplayQueue({
rowsToStick: [...rowsToStick],
stickyStates: [...stickyStates],
position,
});
}

// Coalesce with other sticky row updates (top/bottom), sticky columns updates
// (and potentially other changes like column resize).
this._coalescedStyleScheduler.schedule(() => {
Expand Down Expand Up @@ -440,24 +462,55 @@ export class StickyStyler {

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

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

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

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

return {width: 0, height: 0};
}

private _enqueueReadSize(element: HTMLElement): void {
this._readSizeQueue.push(element);

if (!this._readSizeTimeout) {
this._readSizeTimeout = setTimeout(() => {
this._readSizeTimeout = null;

let needsReplay = false;
for (const e of this._readSizeQueue) {
if (this._elemSizeCache.get(e) != null) {
continue;
}

const size = this._retrieveElementSizeImmediate(e);
this._elemSizeCache.set(e, size);
needsReplay = true;
}
this._readSizeQueue = [];

if (needsReplay && !this._stickyReplayTimeout) {
this._scheduleStickReplay();
}
}, 10);
}
}

/**
* Returns the size of the specified element by direct measurement.
*/
private _retrieveElementSizeImmediate(element: HTMLElement): {width: number; height: number} {
const clientRect = element.getBoundingClientRect();
return {width: clientRect.width, height: clientRect.height};
}

/**
Expand All @@ -468,7 +521,7 @@ export class StickyStyler {
this._removeFromStickyColumnReplayQueue(params.rows);

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

Expand All @@ -486,9 +539,22 @@ export class StickyStyler {
);
}

private _updateStickRowsReplayQueue(params: UpdateStickRowsParams) {
// No need to replay if a flush is pending.
if (this._stickyReplayTimeout) {
return;
}

this._updatedStickRowsParamsToReplay = this._updatedStickRowsParamsToReplay.filter(
entry => entry.position !== params.position,
);

this._updatedStickRowsParamsToReplay.push(params);
}

/** Update _elemSizeCache with the observed sizes. */
private _updateCachedSizes(entries: ResizeObserverEntry[]) {
let needsColumnUpdate = false;
let needsUpdate = false;
for (const entry of entries) {
const newEntry = entry.borderBoxSize?.length
? {
Expand All @@ -500,35 +566,52 @@ export class StickyStyler {
height: entry.contentRect.height,
};

const cachedSize = this._elemSizeCache.get(entry.target as HTMLElement);
if (
newEntry.width !== this._elemSizeCache.get(entry.target as HTMLElement)?.width &&
isCell(entry.target)
(newEntry.width !== cachedSize?.width && isCell(entry.target)) ||
(newEntry.height !== cachedSize?.height && isRow(entry.target))
) {
needsColumnUpdate = true;
needsUpdate = true;
}

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

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

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);
/** Schedule a defered replay of enqueued sticky column operations. */
private _scheduleStickReplay() {
if (
!this._updatedStickyColumnsParamsToReplay.length &&
!this._updatedStickRowsParamsToReplay.length
) {
return;
}

if (this._stickyReplayTimeout) {
clearTimeout(this._stickyReplayTimeout);
}

this._stickyReplayTimeout = setTimeout(() => {
for (const update of this._updatedStickyColumnsParamsToReplay) {
this.updateStickyColumns(
update.rows,
update.stickyStartStates,
update.stickyEndStates,
true,
false,
);
}
for (const update of this._updatedStickRowsParamsToReplay) {
this.stickRows(update.rowsToStick, update.stickyStates, update.position, false);
}
this._updatedStickyColumnsParamsToReplay = [];
this._updatedStickRowsParamsToReplay = [];
this._stickyReplayTimeout = null;
}, 0);
}
}

Expand All @@ -537,3 +620,9 @@ function isCell(element: Element) {
element.classList.contains(klass),
);
}

function isRow(element: Element) {
return ['cdk-row', 'cdk-header-row', 'cdk-footer-row'].some(klass =>
element.classList.contains(klass),
);
}
Loading

0 comments on commit 4192088

Please sign in to comment.