From 3e86ba034b6710e27cf21003ea687d706298a973 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 14 Apr 2026 13:58:00 +0000 Subject: [PATCH] fix(dees-table): stabilize live updates by reusing row DOM and avoiding redundant layout recalculations --- changelog.md | 9 + test/test.dees-table-liveupdates.chromium.ts | 167 +++++++++++++++ ts_web/00_commitinfo_data.ts | 2 +- .../00group-dataview/dees-table/dees-table.ts | 196 +++++++++--------- 4 files changed, 274 insertions(+), 100 deletions(-) create mode 100644 test/test.dees-table-liveupdates.chromium.ts diff --git a/changelog.md b/changelog.md index 8da8a50..ffcc12f 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,14 @@ # Changelog +## 2026-04-14 - 3.78.3 - fix(dees-table) +stabilize live updates by reusing row DOM and avoiding redundant layout recalculations + +- reuse keyed table rows across live-sorted updates so existing row elements persist while cells reorder +- limit flash animation restarts to changed cells by tracking per-cell flash tokens +- avoid repeated column width measurements unless table layout inputs actually change +- replace async header and footer action rendering with direct mapped output to prevent comment node growth during updates +- add Chromium live update tests covering width measurement stability, comment growth, and row DOM reuse + ## 2026-04-12 - 3.78.2 - fix(deps) bump @design.estate/dees-wcctools to ^3.9.0 diff --git a/test/test.dees-table-liveupdates.chromium.ts b/test/test.dees-table-liveupdates.chromium.ts new file mode 100644 index 0000000..a235396 --- /dev/null +++ b/test/test.dees-table-liveupdates.chromium.ts @@ -0,0 +1,167 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import * as deesCatalog from '../ts_web/index.js'; +import type { + Column, + ISortDescriptor, +} from '../ts_web/elements/00group-dataview/dees-table/index.js'; + +interface ITestRow { + id: string; + score: number; + label: string; +} + +const testColumns: Column[] = [ + { key: 'id', header: 'ID' }, + { key: 'score', header: 'Score' }, + { key: 'label', header: 'Label' }, +]; + +const scoreSort: ISortDescriptor[] = [{ key: 'score', dir: 'desc' }]; + +const waitForNextFrame = async () => { + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); +}; + +const waitForMacrotask = async () => { + await new Promise((resolve) => { + window.setTimeout(() => resolve(), 0); + }); +}; + +const settleTable = async (table: deesCatalog.DeesTable) => { + await table.updateComplete; + await waitForNextFrame(); + await waitForMacrotask(); + await table.updateComplete; +}; + +const createRows = (iteration: number): ITestRow[] => { + const cycle = iteration % 3; + + if (cycle === 0) { + return [ + { id: 'alpha', score: 60, label: `Alpha ${iteration}` }, + { id: 'beta', score: 20, label: `Beta ${iteration}` }, + { id: 'gamma', score: 40, label: `Gamma ${iteration}` }, + ]; + } + + if (cycle === 1) { + return [ + { id: 'alpha', score: 30, label: `Alpha ${iteration}` }, + { id: 'beta', score: 70, label: `Beta ${iteration}` }, + { id: 'gamma', score: 50, label: `Gamma ${iteration}` }, + ]; + } + + return [ + { id: 'alpha', score: 55, label: `Alpha ${iteration}` }, + { id: 'beta', score: 35, label: `Beta ${iteration}` }, + { id: 'gamma', score: 75, label: `Gamma ${iteration}` }, + ]; +}; + +const createTable = ( + rows: ITestRow[], + highlightUpdates: 'none' | 'flash' +): deesCatalog.DeesTable => { + const table = new deesCatalog.DeesTable(); + table.searchable = false; + table.columns = testColumns; + table.rowKey = 'id'; + table.sortBy = scoreSort; + table.highlightUpdates = highlightUpdates; + table.data = rows; + document.body.appendChild(table); + return table; +}; + +const countComments = (root: Node): number => { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_COMMENT); + let count = 0; + while (walker.nextNode()) count++; + return count; +}; + +const getBodyRows = (table: deesCatalog.DeesTable): HTMLTableRowElement[] => + Array.from( + table.shadowRoot?.querySelectorAll('tbody tr[data-row-idx]') ?? [] + ) as HTMLTableRowElement[]; + +const getRenderedRowIds = (table: deesCatalog.DeesTable): string[] => + getBodyRows(table).map((row) => row.cells[0]?.textContent?.trim() ?? ''); + +const getRenderedRowMap = ( + table: deesCatalog.DeesTable +): Map => { + const rowMap = new Map(); + for (const row of getBodyRows(table)) { + const rowId = row.cells[0]?.textContent?.trim() ?? ''; + if (rowId) rowMap.set(rowId, row); + } + return rowMap; +}; + +tap.test('dees-table avoids repeated width measurement and comment growth on live updates', async () => { + const table = new deesCatalog.DeesTable(); + let widthMeasureCalls = 0; + const originalDetermineColumnWidths = table.determineColumnWidths.bind(table); + table.determineColumnWidths = (async () => { + widthMeasureCalls++; + await originalDetermineColumnWidths(); + }) as typeof table.determineColumnWidths; + + table.searchable = false; + table.columns = testColumns; + table.rowKey = 'id'; + table.sortBy = scoreSort; + table.highlightUpdates = 'none'; + table.data = createRows(0); + document.body.appendChild(table); + + try { + await settleTable(table); + + const initialWidthMeasureCalls = widthMeasureCalls; + const initialCommentCount = countComments(table.shadowRoot!); + + expect(initialWidthMeasureCalls).toBeGreaterThan(0); + + for (let iteration = 1; iteration <= 10; iteration++) { + table.data = createRows(iteration); + await settleTable(table); + } + + expect(widthMeasureCalls).toEqual(initialWidthMeasureCalls); + expect(countComments(table.shadowRoot!)).toEqual(initialCommentCount); + } finally { + table.remove(); + } +}); + +tap.test('dees-table reuses row DOM while flashing live-sorted updates', async () => { + const table = createTable(createRows(0), 'flash'); + + try { + await settleTable(table); + + const initialRowMap = getRenderedRowMap(table); + + table.data = createRows(1); + await settleTable(table); + + const updatedRowMap = getRenderedRowMap(table); + + expect(getRenderedRowIds(table)).toEqual(['beta', 'gamma', 'alpha']); + expect(updatedRowMap.get('alpha')).toEqual(initialRowMap.get('alpha')); + expect(updatedRowMap.get('beta')).toEqual(initialRowMap.get('beta')); + expect(updatedRowMap.get('gamma')).toEqual(initialRowMap.get('gamma')); + } finally { + table.remove(); + } +}); + +export default tap.start(); diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 97d95ef..15624b7 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@design.estate/dees-catalog', - version: '3.78.2', + version: '3.78.3', description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.' } diff --git a/ts_web/elements/00group-dataview/dees-table/dees-table.ts b/ts_web/elements/00group-dataview/dees-table/dees-table.ts index 036cc93..7cf5222 100644 --- a/ts_web/elements/00group-dataview/dees-table/dees-table.ts +++ b/ts_web/elements/00group-dataview/dees-table/dees-table.ts @@ -293,9 +293,9 @@ export class DeesTable extends DeesElement { private accessor __floatingActive: boolean = false; // ─── Flash-on-update state (only populated when highlightUpdates === 'flash') ── - /** rowId → set of colKey strings currently flashing. */ + /** rowId → (colKey → flash token) for cells currently flashing. */ @state() - private accessor __flashingCells: Map> = new Map(); + private accessor __flashingCells: Map> = new Map(); /** rowId → (colKey → last-seen resolved cell value). Populated per diff pass. */ private __prevSnapshot?: Map>; @@ -303,7 +303,7 @@ export class DeesTable extends DeesElement { /** Single shared timer that clears __flashingCells after highlightDuration ms. */ private __flashClearTimer?: ReturnType; - /** Monotonic counter bumped each flash batch so directives.keyed recreates the cell node and restarts the animation. */ + /** Monotonic counter bumped per flash batch so only changed cells restart their animation. */ private __flashTick: number = 0; /** One-shot console.warn gate for missing rowKey in flash mode. */ @@ -317,7 +317,7 @@ export class DeesTable extends DeesElement { columns: any; augment: boolean; displayFunction: any; - data: any; + displayShapeKey: string; out: Column[]; }; private __memoViewData?: { @@ -329,8 +329,13 @@ export class DeesTable extends DeesElement { effectiveColumns: Column[]; out: T[]; }; - /** Tracks the (data, columns) pair that `determineColumnWidths()` last sized for. */ - private __columnsSizedFor?: { data: any; columns: any }; + /** Tracks the layout inputs that `determineColumnWidths()` last sized for. */ + private __columnsSizedFor?: { + effectiveColumns: Column[]; + showSelectionCheckbox: boolean; + inRowActionCount: number; + table: HTMLTableElement; + }; // ─── Virtualization state ──────────────────────────────────────────── /** Estimated row height (px). Measured once from the first rendered row. */ @@ -409,15 +414,7 @@ export class DeesTable extends DeesElement { const view: T[] = (this as any)._lastViewData ?? []; const item = view.find((r) => this.getRowId(r) === this.__focusedCell!.rowId); if (!item) return; - const allCols: Column[] = - Array.isArray(this.columns) && this.columns.length > 0 - ? computeEffectiveColumnsFn( - this.columns, - this.augmentFromDisplayFunction, - this.displayFunction, - this.data - ) - : computeColumnsFromDisplayFunctionFn(this.displayFunction, this.data); + const allCols = this.__getEffectiveColumns(); const col = allCols.find((c) => String(c.key) === this.__focusedCell!.colKey); if (!col || !this.__isColumnEditable(col)) return; eventArg.preventDefault(); @@ -469,15 +466,24 @@ export class DeesTable extends DeesElement { * that affect it. Avoids re-running `computeEffectiveColumnsFn` / * `computeColumnsFromDisplayFunctionFn` on every Lit update. */ + private __getDisplayFunctionShapeKey(): string { + if (!this.data || this.data.length === 0) return ''; + const firstTransformedItem = this.displayFunction(this.data[0]) ?? {}; + return Object.keys(firstTransformedItem).join('\u0000'); + } + private __getEffectiveColumns(): Column[] { const usingColumns = Array.isArray(this.columns) && this.columns.length > 0; + const displayShapeKey = !usingColumns || this.augmentFromDisplayFunction + ? this.__getDisplayFunctionShapeKey() + : ''; const cache = this.__memoEffectiveCols; if ( cache && cache.columns === this.columns && cache.augment === this.augmentFromDisplayFunction && cache.displayFunction === this.displayFunction && - cache.data === this.data + cache.displayShapeKey === displayShapeKey ) { return cache.out; } @@ -493,7 +499,7 @@ export class DeesTable extends DeesElement { columns: this.columns, augment: this.augmentFromDisplayFunction, displayFunction: this.displayFunction, - data: this.data, + displayShapeKey, out, }; return out; @@ -543,6 +549,9 @@ export class DeesTable extends DeesElement { public render(): TemplateResult { const effectiveColumns = this.__getEffectiveColumns(); const viewData = this.__getViewData(effectiveColumns); + const headerActions = this.getActionsForType('header'); + const footerActions = this.getActionsForType('footer'); + const inRowActions = this.getActionsForType('inRow'); (this as any)._lastViewData = viewData; // Virtualization slice — only the rows in `__virtualRange` actually @@ -572,29 +581,22 @@ export class DeesTable extends DeesElement {
${this.heading2}
- ${directives.resolveExec(async () => { - const resultArray: TemplateResult[] = []; - for (const action of this.dataActions) { - if (!action.type?.includes('header')) continue; - resultArray.push( - html`
{ - action.actionFunc({ - item: this.selectedDataRow, - table: this, - }); - }} - > - ${action.iconName - ? html` - ${action.name}` - : action.name} -
` - ); - } - return resultArray; - })} + ${headerActions.map( + (action) => html`
{ + action.actionFunc({ + item: this.selectedDataRow, + table: this, + }); + }} + > + ${action.iconName + ? html` + ${action.name}` + : action.name} +
` + )}
@@ -658,11 +660,11 @@ export class DeesTable extends DeesElement { : html``} ${directives.repeat( renderRows, - (itemArg, sliceIdx) => `${this.getRowId(itemArg)}::${renderStart + sliceIdx}`, + (itemArg) => this.getRowId(itemArg), (itemArg, sliceIdx) => { const rowIndex = renderStart + sliceIdx; const rowId = this.getRowId(itemArg); - const flashSet = this.__flashingCells.get(rowId); + const flashTokens = this.__flashingCells.get(rowId); return html` extends DeesElement { const isEditing = this.__editingCell?.rowId === rowId && this.__editingCell?.colKey === editKey; - const isFlashing = !!flashSet?.has(editKey); + const flashToken = flashTokens?.get(editKey); + const isFlashing = flashToken !== undefined; const useFlashBorder = isFlashing && !!col.flashBorder; const cellClasses = [ isEditable ? 'editable' : '', @@ -720,7 +723,7 @@ export class DeesTable extends DeesElement { > ${isFlashing ? directives.keyed( - `${rowId}:${editKey}:${this.__flashTick}`, + `${rowId}:${editKey}:${flashToken}`, innerHtml ) : innerHtml} @@ -728,11 +731,11 @@ export class DeesTable extends DeesElement { `; })} ${(() => { - if (this.dataActions && this.dataActions.length > 0) { + if (inRowActions.length > 0) { return html`
- ${this.getActionsForType('inRow').map( + ${inRowActions.map( (actionArg) => html`
extends DeesElement { selected
- ${directives.resolveExec(async () => { - const resultArray: TemplateResult[] = []; - for (const action of this.dataActions) { - if (!action.type?.includes('footer')) continue; - resultArray.push( - html`
{ - action.actionFunc({ - item: this.selectedDataRow, - table: this, - }); - }} - > - ${action.iconName - ? html` - ${action.name}` - : action.name} -
` - ); - } - return resultArray; - })} + ${footerActions.map( + (action) => html`
{ + action.actionFunc({ + item: this.selectedDataRow, + table: this, + }); + }} + > + ${action.iconName + ? html` + ${action.name}` + : action.name} +
` + )}
@@ -1160,7 +1156,7 @@ export class DeesTable extends DeesElement { /** * Measures the height of the first rendered body row and stores it for * subsequent virtualization math. Idempotent — only measures once per - * `data`/`columns` pair (cleared in `updated()` when those change). + * rendered table layout (cleared in `updated()` when that layout changes). */ private __measureRowHeight() { if (!this.virtualized || this.__rowHeightMeasured) return; @@ -1426,20 +1422,16 @@ export class DeesTable extends DeesElement { if (newlyFlashing.size === 0) return; // Merge with any in-flight flashes from a rapid second update so a cell - // that changes twice before its animation ends gets a single clean - // restart (via __flashTick / directives.keyed) instead of stacking. + // that changes twice before its animation ends gets a clean restart, + // while unrelated cells keep their existing DOM subtree. + const flashToken = ++this.__flashTick; + const nextFlashingCells = new Map(this.__flashingCells); for (const [rowId, cols] of newlyFlashing) { - const existing = this.__flashingCells.get(rowId); - if (existing) { - for (const c of cols) existing.add(c); - } else { - this.__flashingCells.set(rowId, cols); - } + const existing = new Map(nextFlashingCells.get(rowId) ?? []); + for (const colKey of cols) existing.set(colKey, flashToken); + nextFlashingCells.set(rowId, existing); } - this.__flashTick++; - // Reactivity nudge: we've mutated the Map in place, so give Lit a fresh - // reference so the @state change fires for render. - this.__flashingCells = new Map(this.__flashingCells); + this.__flashingCells = nextFlashingCells; if (this.__flashClearTimer) clearTimeout(this.__flashClearTimer); this.__flashClearTimer = setTimeout(() => { this.__flashingCells = new Map(); @@ -1449,6 +1441,9 @@ export class DeesTable extends DeesElement { public async updated(changedProperties: Map): Promise { super.updated(changedProperties); + const effectiveColumns = this.__getEffectiveColumns(); + const currentTable = this.shadowRoot?.querySelector('table') ?? null; + const inRowActionCount = this.getActionsForType('inRow').length; // Feed highlightDuration into the CSS variable so JS and CSS stay in // sync via a single source of truth. @@ -1456,15 +1451,23 @@ export class DeesTable extends DeesElement { this.style.setProperty('--dees-table-flash-duration', `${this.highlightDuration}ms`); } - // Only re-measure column widths when the data or schema actually changed - // (or on first paint). `determineColumnWidths` is the single biggest - // first-paint cost — it forces multiple layout flushes per row. - const dataOrColsChanged = - !this.__columnsSizedFor || - this.__columnsSizedFor.data !== this.data || - this.__columnsSizedFor.columns !== this.columns; - if (dataOrColsChanged) { - this.__columnsSizedFor = { data: this.data, columns: this.columns }; + // Only re-measure column widths when layout-affecting inputs changed or + // when a new element was rendered after previously having none. + const columnLayoutChanged = + !!currentTable && ( + !this.__columnsSizedFor || + this.__columnsSizedFor.effectiveColumns !== effectiveColumns || + this.__columnsSizedFor.showSelectionCheckbox !== this.showSelectionCheckbox || + this.__columnsSizedFor.inRowActionCount !== inRowActionCount || + this.__columnsSizedFor.table !== currentTable + ); + if (currentTable && columnLayoutChanged) { + this.__columnsSizedFor = { + effectiveColumns, + showSelectionCheckbox: this.showSelectionCheckbox, + inRowActionCount, + table: currentTable, + }; this.determineColumnWidths(); // Force re-measure of row height; structure may have changed. this.__rowHeightMeasured = false; @@ -1502,7 +1505,7 @@ export class DeesTable extends DeesElement { if ( !this.fixedHeight && this.data.length > 0 && - (this.__floatingActive || dataOrColsChanged) + (this.__floatingActive || columnLayoutChanged) ) { this.__syncFloatingHeader(); } @@ -1804,10 +1807,7 @@ export class DeesTable extends DeesElement { * Used by the modal helper to render human-friendly labels. */ private _lookupColumnByKey(key: string): Column | undefined { - const usingColumns = Array.isArray(this.columns) && this.columns.length > 0; - const effective = usingColumns - ? computeEffectiveColumnsFn(this.columns, this.augmentFromDisplayFunction, this.displayFunction, this.data) - : computeColumnsFromDisplayFunctionFn(this.displayFunction, this.data); + const effective = this.__getEffectiveColumns(); return effective.find((c) => String(c.key) === key); } @@ -2543,9 +2543,7 @@ export class DeesTable extends DeesElement { const view: T[] = (this as any)._lastViewData ?? []; if (view.length === 0) return; // Recompute editable columns from the latest effective set. - const allCols: Column[] = Array.isArray(this.columns) && this.columns.length > 0 - ? computeEffectiveColumnsFn(this.columns, this.augmentFromDisplayFunction, this.displayFunction, this.data) - : computeColumnsFromDisplayFunctionFn(this.displayFunction, this.data); + const allCols = this.__getEffectiveColumns(); const editableCols = this.__editableColumns(allCols); if (editableCols.length === 0) return;