diff --git a/src/cdk-experimental/table-scroll-container/table-scroll-container.spec.ts b/src/cdk-experimental/table-scroll-container/table-scroll-container.spec.ts index 009e971b6b20..8ddee8ec3589 100644 --- a/src/cdk-experimental/table-scroll-container/table-scroll-container.spec.ts +++ b/src/cdk-experimental/table-scroll-container/table-scroll-container.spec.ts @@ -39,7 +39,7 @@ describe('CdkTableScrollContainer', () => { } async function waitForLayout(): Promise { - 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 diff --git a/src/cdk/table/sticky-styler.ts b/src/cdk/table/sticky-styler.ts index 286cabacc6be..9043f4812b48 100644 --- a/src/cdk/table/sticky-styler.ts +++ b/src/cdk/table/sticky-styler.ts @@ -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 @@ -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}>; @@ -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(() => { @@ -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}; } /** @@ -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; } @@ -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 ? { @@ -496,39 +562,56 @@ export class StickyStyler { height: entry.borderBoxSize[0].blockSize, } : { - width: entry.contentRect.width, - height: entry.contentRect.height, + width: (entry.target as HTMLElement).offsetWidth, + height: (entry.target as HTMLElement).offsetHeight, }; + 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); } } @@ -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), + ); +} diff --git a/src/cdk/table/table.spec.ts b/src/cdk/table/table.spec.ts index 711f19849def..a3b200469171 100644 --- a/src/cdk/table/table.spec.ts +++ b/src/cdk/table/table.spec.ts @@ -984,7 +984,7 @@ describe('CdkTable', () => { component.stickyHeaders = ['header-1', 'header-3']; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); - await new Promise(r => setTimeout(r)); + await sleep(); expectStickyStyles(headerRows[0], '100', {top: '0px'}); expectStickyBorderClass(headerRows[0]); @@ -1013,7 +1013,9 @@ describe('CdkTable', () => { component.stickyHeaders = []; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); - await new Promise(r => setTimeout(r)); + await sleep(); + await sleep(); + expectNoStickyStyles(headerRows); expect(component.mostRecentStickyHeaderRowsUpdate).toEqual({ sizes: [], @@ -1033,7 +1035,7 @@ describe('CdkTable', () => { component.stickyFooters = ['footer-1', 'footer-3']; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); - await new Promise(r => setTimeout(r)); + await sleep(); expectStickyStyles(footerRows[0], '10', { bottom: footerRows[1].getBoundingClientRect().height + 'px', @@ -1062,7 +1064,7 @@ describe('CdkTable', () => { component.stickyFooters = []; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); - await new Promise(r => setTimeout(r)); + await sleep(); expectNoStickyStyles(footerRows); expect(component.mostRecentStickyHeaderRowsUpdate).toEqual({ sizes: [], @@ -1082,7 +1084,7 @@ describe('CdkTable', () => { component.stickyFooters = ['footer-3']; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); - await new Promise(r => setTimeout(r)); + await sleep(); expectStickyStyles(footerRows[2], '10', {bottom: '0px'}); expectStickyBorderClass(footerRows[2], {bottom: true}); @@ -1093,7 +1095,7 @@ describe('CdkTable', () => { component.stickyStartColumns = ['column-1', 'column-3']; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); - await new Promise(r => setTimeout(r)); + await sleep(); headerRows.forEach(row => { let cells = getHeaderCells(row); @@ -1119,6 +1121,7 @@ describe('CdkTable', () => { expectStickyBorderClass(cells[2], {left: true}); expectNoStickyStyles([cells[1], cells[3], cells[4], cells[5]]); }); + expect(component.mostRecentStickyHeaderRowsUpdate).toEqual({ sizes: [], offsets: [], @@ -1141,7 +1144,7 @@ describe('CdkTable', () => { component.stickyStartColumns = []; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); - await new Promise(r => setTimeout(r)); + await sleep(); headerRows.forEach(row => expectNoStickyStyles(getHeaderCells(row))); dataRows.forEach(row => expectNoStickyStyles(getCells(row))); footerRows.forEach(row => expectNoStickyStyles(getFooterCells(row))); @@ -1163,7 +1166,7 @@ describe('CdkTable', () => { component.stickyEndColumns = ['column-4', 'column-6']; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); - await new Promise(r => setTimeout(r)); + await sleep(); headerRows.forEach(row => { let cells = getHeaderCells(row); @@ -1189,6 +1192,7 @@ describe('CdkTable', () => { expectStickyBorderClass(cells[3], {right: true}); expectNoStickyStyles([cells[0], cells[1], cells[2], cells[4]]); }); + expect(component.mostRecentStickyHeaderRowsUpdate).toEqual({ sizes: [], offsets: [], @@ -1211,7 +1215,7 @@ describe('CdkTable', () => { component.stickyEndColumns = []; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); - await new Promise(r => setTimeout(r)); + await sleep(); headerRows.forEach(row => expectNoStickyStyles(getHeaderCells(row))); dataRows.forEach(row => expectNoStickyStyles(getCells(row))); footerRows.forEach(row => expectNoStickyStyles(getFooterCells(row))); @@ -1235,7 +1239,7 @@ describe('CdkTable', () => { component.stickyEndColumns = ['column-5', 'column-6']; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); - await new Promise(r => setTimeout(r)); + await sleep(); const firstColumnWidth = getHeaderCells(headerRows[0])[0].getBoundingClientRect().width; const lastColumnWidth = getHeaderCells(headerRows[0])[5].getBoundingClientRect().width; @@ -1280,7 +1284,7 @@ describe('CdkTable', () => { component.stickyEndColumns = ['column-6']; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); - await new Promise(r => setTimeout(r)); + await sleep(); let headerCells = getHeaderCells(headerRows[0]); expectStickyStyles(headerRows[0], '100', {top: '0px'}); @@ -1334,7 +1338,7 @@ describe('CdkTable', () => { component.stickyEndColumns = []; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); - await new Promise(r => setTimeout(r)); + await sleep(); headerRows.forEach(row => expectNoStickyStyles([row, ...getHeaderCells(row)])); dataRows.forEach(row => expectNoStickyStyles([row, ...getCells(row)])); @@ -1372,7 +1376,7 @@ describe('CdkTable', () => { component.stickyHeaders = ['header-1', 'header-3']; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); - await new Promise(r => setTimeout(r)); + await sleep(); getHeaderCells(headerRows[0]).forEach(cell => { expectStickyStyles(cell, '100', {top: '0px'}); @@ -1405,7 +1409,7 @@ describe('CdkTable', () => { component.stickyHeaders = []; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); - await new Promise(r => setTimeout(r)); + await sleep(); expectNoStickyStyles(headerRows); // No sticky styles on rows for native table headerRows.forEach(row => expectNoStickyStyles(getHeaderCells(row))); expect(component.mostRecentStickyHeaderRowsUpdate).toEqual({ @@ -1426,7 +1430,7 @@ describe('CdkTable', () => { component.stickyFooters = ['footer-1', 'footer-3']; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); - await new Promise(r => setTimeout(r)); + await sleep(); getFooterCells(footerRows[2]).forEach(cell => { expectStickyStyles(cell, '10', {bottom: '0px'}); @@ -1459,7 +1463,7 @@ describe('CdkTable', () => { component.stickyFooters = []; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); - await new Promise(r => setTimeout(r)); + await sleep(); expectNoStickyStyles(footerRows); // No sticky styles on rows for native table footerRows.forEach(row => expectNoStickyStyles(getFooterCells(row))); expect(component.mostRecentStickyHeaderRowsUpdate).toEqual({ @@ -1481,20 +1485,20 @@ describe('CdkTable', () => { component.stickyFooters = ['footer-1']; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); - await new Promise(r => setTimeout(r)); + await sleep(); expectNoStickyStyles([tfoot]); component.stickyFooters = ['footer-1', 'footer-2', 'footer-3']; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); - await new Promise(r => setTimeout(r)); + await sleep(); expectStickyStyles(tfoot, '10', {bottom: '0px'}); expectStickyBorderClass(tfoot); component.stickyFooters = ['footer-1', 'footer-2']; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); - await new Promise(r => setTimeout(r)); + await sleep(); expectNoStickyStyles([tfoot]); })); @@ -1502,7 +1506,7 @@ describe('CdkTable', () => { component.stickyStartColumns = ['column-1', 'column-3']; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); - await new Promise(r => setTimeout(r)); + await sleep(); headerRows.forEach(row => { let cells = getHeaderCells(row); @@ -1550,7 +1554,7 @@ describe('CdkTable', () => { component.stickyStartColumns = []; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); - await new Promise(r => setTimeout(r)); + await sleep(); headerRows.forEach(row => expectNoStickyStyles(getHeaderCells(row))); dataRows.forEach(row => expectNoStickyStyles(getCells(row))); footerRows.forEach(row => expectNoStickyStyles(getFooterCells(row))); @@ -1572,7 +1576,7 @@ describe('CdkTable', () => { component.stickyEndColumns = ['column-4', 'column-6']; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); - await new Promise(r => setTimeout(r)); + await sleep(); headerRows.forEach(row => { let cells = getHeaderCells(row); @@ -1620,7 +1624,7 @@ describe('CdkTable', () => { component.stickyEndColumns = []; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); - await new Promise(r => setTimeout(r)); + await sleep(); headerRows.forEach(row => expectNoStickyStyles(getHeaderCells(row))); dataRows.forEach(row => expectNoStickyStyles(getCells(row))); footerRows.forEach(row => expectNoStickyStyles(getFooterCells(row))); @@ -1645,7 +1649,7 @@ describe('CdkTable', () => { component.stickyEndColumns = ['column-6']; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); - await new Promise(r => setTimeout(r)); + await sleep(); const headerCells = getHeaderCells(headerRows[0]); expectStickyStyles(headerCells[0], '101', {top: '0px', left: '0px'}); @@ -1709,7 +1713,7 @@ describe('CdkTable', () => { component.stickyEndColumns = []; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); - await new Promise(r => setTimeout(r)); + await sleep(); headerRows.forEach(row => expectNoStickyStyles([row, ...getHeaderCells(row)])); dataRows.forEach(row => expectNoStickyStyles([row, ...getCells(row)])); @@ -3274,3 +3278,10 @@ export function expectTableToMatchContent(tableElement: Element, expected: any[] fail(missedExpectations.join('\n')); } } + +/** async wrapper for setTimeout. */ +async function sleep(ms = 20) { + // setTimeout does not actually return void. + // tslint:disable-next-line:g3-no-void-expression + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/tools/public_api_guard/cdk/table.md b/tools/public_api_guard/cdk/table.md index 21da470d629a..be3bc9327a4e 100644 --- a/tools/public_api_guard/cdk/table.md +++ b/tools/public_api_guard/cdk/table.md @@ -564,7 +564,7 @@ export class StickyStyler { _getStickyEndColumnPositions(widths: number[], stickyStates: boolean[]): number[]; _getStickyStartColumnPositions(widths: number[], stickyStates: boolean[]): number[]; _removeStickyStyle(element: HTMLElement, stickyDirections: StickyDirection[]): void; - stickRows(rowsToStick: HTMLElement[], stickyStates: boolean[], position: 'top' | 'bottom'): void; + stickRows(rowsToStick: HTMLElement[], stickyStates: boolean[], position: 'top' | 'bottom', replay?: boolean): void; updateStickyColumns(rows: HTMLElement[], stickyStartStates: boolean[], stickyEndStates: boolean[], recalculateCellWidths?: boolean, replay?: boolean): void; updateStickyFooterContainer(tableElement: Element, stickyStates: boolean[]): void; }