From 58793cd5dae72bd5c18d3d54e6e708cb25c855c2 Mon Sep 17 00:00:00 2001 From: Andrew Stein Date: Thu, 9 Jul 2026 00:33:53 -0400 Subject: [PATCH] Instance column size CSS and general stylesheet optimization --- src/ts/events.ts | 20 +++- src/ts/scroll_panel.ts | 29 ++--- src/ts/table.ts | 252 +++++++++++++++++++++++++++-------------- src/ts/tbody.ts | 13 ++- src/ts/thead.ts | 7 ++ src/ts/view_model.ts | 45 ++++++++ 6 files changed, 266 insertions(+), 100 deletions(-) diff --git a/src/ts/events.ts b/src/ts/events.ts index e9ae1df..05d7561 100644 --- a/src/ts/events.ts +++ b/src/ts/events.ts @@ -56,6 +56,17 @@ export class RegularViewEventModel extends RegularVirtualTableViewModel { */ async _on_scroll(event: Event) { event.stopPropagation(); + if (this._scroll_pending) { + return; + } + + this._scroll_pending = true; + try { + await new Promise(requestAnimationFrame); + } finally { + this._scroll_pending = false; + } + await this.draw({ invalid_viewport: false, cache: true }); this.dispatchEvent(new CustomEvent("regular-table-scroll")); } @@ -221,6 +232,7 @@ export class RegularViewEventModel extends RegularVirtualTableViewModel { } td.classList.remove("rt-cell-clip"); + this.table_model.body._untagColumn(td); } } @@ -372,11 +384,17 @@ export class RegularViewEventModel extends RegularVirtualTableViewModel { // Update header clipping class th.classList.toggle("rt-cell-clip", should_clip); - // Update body cell clipping classes + // Update body cell clipping classes. Clipped cells must carry the + // column tag so the override's `max-width` clamp reaches them. for (const row of this.table_model.body.cells) { const td = row[virtual_x]; if (td) { td.classList.toggle("rt-cell-clip", should_clip); + if (should_clip) { + this.table_model.body._tagColumn(td, size_key); + } else { + this.table_model.body._untagColumn(td); + } } } } diff --git a/src/ts/scroll_panel.ts b/src/ts/scroll_panel.ts index 3908267..b844e6c 100644 --- a/src/ts/scroll_panel.ts +++ b/src/ts/scroll_panel.ts @@ -14,7 +14,6 @@ import { DEBUG, BROWSER_MAX_HEIGHT } from "./constants"; import type { RegularTableViewModel } from "./table"; import { RegularTableElement } from "./regular-table"; import { - CellTuple, ColumnSizes, StyleCallback, ContainerSize, @@ -95,6 +94,7 @@ export class RegularVirtualTableViewModel extends HTMLElement { protected _is_styling?: boolean; protected table_model!: RegularTableViewModel; protected _style_callbacks!: Array; + protected _scroll_pending?: boolean; private _probe_element?: [HTMLElement, HTMLElement]; /** @@ -121,7 +121,11 @@ export class RegularVirtualTableViewModel extends HTMLElement { * Await any pending rendering operations. */ async flush(): Promise { - await flush_tag(this); + // A scroll-driven draw may still be waiting on its animation frame + // (see `_on_scroll`); keep flushing until none is pending. + do { + await flush_tag(this); + } while (this._scroll_pending); } /** @@ -309,10 +313,7 @@ export class RegularVirtualTableViewModel extends HTMLElement { * * @param viewport */ - protected _update_sub_cell_offset( - viewport: Viewport, - last_cell?: CellTuple, - ): void { + protected _update_sub_cell_offset(viewport: Viewport): void { const y_offset = (this._column_sizes.row_height || 0) * (viewport.start_row % 1) || 0; @@ -321,13 +322,9 @@ export class RegularVirtualTableViewModel extends HTMLElement { (this.table_model._row_headers_length || 0) + Math.floor(viewport.start_col); - if (this._column_sizes.indices[x_offset_index] === undefined) { - this._column_sizes.indices[x_offset_index] = - last_cell?.[0]?.offsetWidth || 0; - } - const x_offset = - this._column_sizes.indices[x_offset_index]! * + (this._column_sizes.indices[x_offset_index] ?? + this.table_model._estimated_column_width()) * (viewport.start_col % 1) || 0; this._sub_cell_rule.style.setProperty(CLIP_X, `${x_offset}px`); @@ -592,12 +589,12 @@ async function internal_draw( preserve_width, viewport, safe_num_columns, - async (last_cells) => { + async () => { // We want to perform this before the next event loop so there // is no scroll jitter, but only on the first iteration as // subsequent viewports are incorrect. if (first_iteration) { - this._update_sub_cell_offset(viewport, last_cells); + this._update_sub_cell_offset(viewport); first_iteration = false; } @@ -607,6 +604,10 @@ async function internal_draw( }, ); + // Re-apply with post-measurement widths, in case the first + // application used an estimate for a never-measured leading column. + this._update_sub_cell_offset(viewport); + const old_height = this._column_sizes.row_height; this.table_model.header.reset_header_cache(); if (old_height !== this._column_sizes.row_height) { diff --git a/src/ts/table.ts b/src/ts/table.ts index bac8320..385d9c7 100644 --- a/src/ts/table.ts +++ b/src/ts/table.ts @@ -73,6 +73,27 @@ abstract class RegularTableViewModelBase { }; } + /** + * Estimate the width of a never-measured column from the average of all + * measured columns, falling back to the same 60px guess used by the + * scroll-range math. Used in place of mid-loop `offsetWidth` reads (which + * force a synchronous layout per unvisited column); the viewport-filled + * check re-validates against real measurements after the single batched + * read pass in `autosize_cells`. + */ + _estimated_column_width(): number { + let total = 0, + count = 0; + for (const w of this._column_sizes.indices) { + if (w) { + total += w; + count++; + } + } + + return count ? total / count : 60; + } + /** * Draws row headers and returns updated state. */ @@ -331,10 +352,14 @@ abstract class RegularTableViewModelBase { * * @class RegularTableViewModel */ +let _instance_counter = 0; + export class RegularTableViewModel extends RegularTableViewModelBase { public table: HTMLTableElement; private _columnWidthStyleSheet?: CSSStyleSheet; - private _lastColumnWidthCss?: string; + private _columnWidthRules: Map = new Map(); + private _columnWidthCache: Map = new Map(); + private _scope_class: string; private _lastDataResponse?: DataResponse; private _lastViewport?: Viewport; @@ -351,6 +376,13 @@ export class RegularTableViewModel extends RegularTableViewModelBase { table.children as HTMLCollectionOf; this.table = table; + + // Instance-scoped class placed on the `` so that this instance's + // generated column-width rules (`.rt-scope-N .rt-col-K`) only match this + // instance's cells, even when several ``s share a root. + this._scope_class = `rt-scope-${_instance_counter++}`; + this.table.classList.add(this._scope_class); + this.header = new RegularHeaderViewModel( column_sizes, table_clip, @@ -420,9 +452,13 @@ export class RegularTableViewModel extends RegularTableViewModelBase { } /** - * Updates column width styles for all columns using adoptedStyleSheets. - * Generates CSS rules with :nth-child selectors for both auto-sized and - * overridden column widths, applying them all in a single stylesheet update. + * Updates the generated column-width rules for the currently-visible + * columns. Each column `size_key` owns exactly one `CSSStyleRule` keyed by a + * stable `.rt-col-{size_key}` class (applied to cells by `_tagColumn`), so + * widths are position-independent — no `:nth-of-type` bookkeeping, no + * colspan/rowspan index gymnastics — and are updated surgically (a single + * `rule.style` mutation for the changed column) instead of by re-serializing + * the whole sheet. * * This method should be called whenever column sizes change, including: * - After autosize_cells() measurements @@ -433,110 +469,135 @@ export class RegularTableViewModel extends RegularTableViewModelBase { viewport: Viewport, row_headers_length: number, ): void { - const cssRules: string[] = []; + if (!this._ensureColumnWidthSheet()) { + return; + } + // Row-header columns are keyed `0..row_headers_length-1`; data columns + // continue from there offset by the horizontal scroll position. These + // are exactly the `metadata.size_key` values carried by the cells. let row_headers_size_key; for ( row_headers_size_key = 0; row_headers_size_key < row_headers_length; row_headers_size_key++ ) { - const override_width = - this._column_sizes.override[row_headers_size_key]; - const auto_width = this._column_sizes.auto[row_headers_size_key]; - if (override_width !== undefined) { - // Override width takes precedence - // CSS :nth-child is 1-indexed - const columnIndex = row_headers_size_key + 1; // - Math.floor(viewport.start_col); - - // Work backwards to handle colspan/rowspan missing elements in - // `row_headers`. - cssRules.push( - `thead tr.rt-autosize th:nth-of-type(${columnIndex}),`, - `tbody th.rt-cell-clip:nth-last-of-type(${row_headers_length - row_headers_size_key})`, - `{min-width:${override_width}px;max-width:${override_width}px;}`, - ); - } else if (auto_width !== undefined) { - // Auto width applies when no override - const columnIndex = row_headers_size_key + 1; // - Math.floor(viewport.start_col); - cssRules.push( - `thead tr.rt-autosize th:nth-of-type(${columnIndex}),`, - `tbody th.rt-cell-clip:nth-last-of-type(${row_headers_length - row_headers_size_key})`, - `{min-width:${auto_width}px;}`, - ); - } + this._updateColumnRule(row_headers_size_key); } + const start_col = Math.floor(viewport.start_col); for ( let size_key = row_headers_size_key; size_key < - row_headers_size_key + - (Math.floor(viewport.end_col) - Math.floor(viewport.start_col)); + row_headers_size_key + (Math.floor(viewport.end_col) - start_col); size_key++ ) { - const override_width = - this._column_sizes.override[ - size_key + Math.floor(viewport.start_col) - ]; - const auto_width = - this._column_sizes.auto[ - size_key + Math.floor(viewport.start_col) - ]; - - if (override_width !== undefined) { - // Override width takes precedence - // CSS :nth-child is 1-indexed - const columnIndex = size_key + 1; // Math.floor(viewport.start_col); - cssRules.push( - `thead tr.rt-autosize th:nth-of-type(${columnIndex}),`, - `tbody td.rt-cell-clip:nth-of-type(${columnIndex - row_headers_size_key})`, - `{min-width:${override_width}px;max-width:${override_width}px;}`, - ); - } else if (auto_width !== undefined) { - // Auto width applies when no override - const columnIndex = size_key + 1; // Math.floor(viewport.start_col); - cssRules.push( - `thead tr.rt-autosize th:nth-of-type(${columnIndex}),`, - `tbody td.rt-cell-clip:nth-of-type(${columnIndex - row_headers_size_key})`, - `{min-width:${auto_width}px;}`, - ); - } - } - - // Apply all rules via single stylesheet update - if (cssRules.length > 0) { - this._applyColumnWidthStyles(cssRules.join("\n")); - } else if (this._columnWidthStyleSheet) { - this._columnWidthStyleSheet.replaceSync(""); + this._updateColumnRule(size_key + start_col); } } /** - * Applies column width styles using adoptedStyleSheets. - * Creates or updates a dedicated stylesheet for column widths. - * Caches the CSS string to avoid redundant replaceSync calls. + * Apply the effective width for a single column `size_key` to its rule. + * Override wins and clamps (min+max); auto is a grow-to-content floor + * (min only); neither present clears the rule. */ - private _applyColumnWidthStyles(css: string): void { - if (css === this._lastColumnWidthCss) { - return; + private _updateColumnRule(size_key: number): void { + const override_width = this._column_sizes.override[size_key]; + const auto_width = this._column_sizes.auto[size_key]; + if (override_width !== undefined) { + this._setColumnRule(size_key, override_width, override_width); + } else if (auto_width !== undefined) { + this._setColumnRule(size_key, auto_width, undefined); + } else { + this._clearColumnRule(size_key); } + } - const shadowRoot = this.table.getRootNode() as ShadowRoot; - if (!shadowRoot || !shadowRoot.adoptedStyleSheets) { - return; + /** + * Lazily create — and re-adopt after a root change — the single stylesheet + * holding this instance's per-column width rules. The sheet is adopted on + * the cells' root node (the `
` is slotted into the light DOM, so a + * sheet on ``'s own shadow root could not reach the cells); + * instance-scoping via `_scope_class` keeps sibling tables from colliding. + * Returns `undefined` when the root does not support `adoptedStyleSheets`. + */ + private _ensureColumnWidthSheet(): CSSStyleSheet | undefined { + const root = this.table.getRootNode() as { + adoptedStyleSheets?: CSSStyleSheet[]; + }; + if (!root || !root.adoptedStyleSheets) { + return undefined; } - // Find or create the column width stylesheet if (!this._columnWidthStyleSheet) { this._columnWidthStyleSheet = new CSSStyleSheet(); - shadowRoot.adoptedStyleSheets = [ - ...shadowRoot.adoptedStyleSheets, + } + + if (!root.adoptedStyleSheets.includes(this._columnWidthStyleSheet)) { + root.adoptedStyleSheets = [ + ...root.adoptedStyleSheets, this._columnWidthStyleSheet, ]; } - this._columnWidthStyleSheet.replaceSync(css); - this._lastColumnWidthCss = css; + return this._columnWidthStyleSheet; + } + + private _getOrCreateColumnRule(size_key: number): CSSStyleRule | undefined { + let rule = this._columnWidthRules.get(size_key); + if (rule) { + return rule; + } + + const sheet = this._columnWidthStyleSheet; + if (!sheet) { + return undefined; + } + + const index = sheet.cssRules.length; + sheet.insertRule(`.${this._scope_class} .rt-col-${size_key}{}`, index); + rule = sheet.cssRules[index] as CSSStyleRule; + this._columnWidthRules.set(size_key, rule); + return rule; + } + + private _setColumnRule( + size_key: number, + min: number, + max: number | undefined, + ): void { + const cache_value = max === undefined ? `${min}` : `${min}|${max}`; + if (this._columnWidthCache.get(size_key) === cache_value) { + return; + } + + const rule = this._getOrCreateColumnRule(size_key); + if (!rule) { + return; + } + + rule.style.minWidth = `${min}px`; + if (max === undefined) { + rule.style.removeProperty("max-width"); + } else { + rule.style.maxWidth = `${max}px`; + } + + this._columnWidthCache.set(size_key, cache_value); + } + + private _clearColumnRule(size_key: number): void { + if (this._columnWidthCache.get(size_key) === "") { + return; + } + + const rule = this._columnWidthRules.get(size_key); + if (rule) { + rule.style.removeProperty("min-width"); + rule.style.removeProperty("max-width"); + } + + this._columnWidthCache.set(size_key, ""); } async _getDimState(view_cache: ViewCache): Promise { @@ -636,8 +697,10 @@ export class RegularTableViewModel extends RegularTableViewModelBase { // Draw data columns try { let dcidx = 0; + let unmeasured_col_width = 0; const num_visible_columns = num_columns - viewport.start_col; + const warm = this._column_sizes.indices.some(Boolean); while (dcidx < num_visible_columns) { // Fetch missing columns if needed if (!view_response.data[dcidx]) { @@ -718,16 +781,25 @@ export class RegularTableViewModel extends RegularTableViewModelBase { } } - // Update dimensions + // Update dimensions. The first never-measured column drawn in + // a pass is read once and its width reused for subsequent + // unmeasured columns, rather than forcing a synchronous layout + // per new column. The viewport-filled check below re-validates + // against real measurements after a batched read pass, so a + // width difference among new columns cannot under-fill. let col_width = this._column_sizes.indices[_virtual_x + Math.floor(x0)] || - cont_head?.th?.offsetWidth; + (warm ? unmeasured_col_width : 0); if (!col_width) { - col_width = 0; - for (const { td } of cont_body.tds) { - col_width += td?.offsetWidth || 0; + col_width = cont_head?.th?.offsetWidth || 0; + if (!col_width) { + for (const { td } of cont_body.tds) { + col_width += td?.offsetWidth || 0; + } } + + unmeasured_col_width = col_width; } view_state.viewport_width += col_width; @@ -747,6 +819,14 @@ export class RegularTableViewModel extends RegularTableViewModelBase { await style_callback(last_cells[this._row_headers_length]); + // `preserve_width` draws skip measurement, so the + // estimate-based filled check above is final; a follow-up + // draw (e.g. on resize mouseup) re-measures. + if (preserve_width) { + this._carriageReturn(); + return; + } + // Recalculate after style listeners view_state.viewport_width = 0; this.autosize_cells( @@ -754,6 +834,10 @@ export class RegularTableViewModel extends RegularTableViewModelBase { this._column_sizes.row_height, ); + // Newly-visited columns are now measured; force a fresh + // read if a later pass draws further unmeasured columns. + unmeasured_col_width = 0; + for (let i = 0; i < last_cells.length; i++) { view_state.viewport_width += this._column_sizes.indices[Math.floor(x0) + i] || 0; diff --git a/src/ts/tbody.ts b/src/ts/tbody.ts index 838ada9..8c03453 100644 --- a/src/ts/tbody.ts +++ b/src/ts/tbody.ts @@ -42,7 +42,11 @@ export class RegularBodyViewModel extends ViewModel { metadata.column_header = column_name; } - // Handle clipping class for overridden columns + // Handle clipping class for overridden columns. A body cell carries + // its column tag only while clipped by an override — the `max-width` + // clamp must reach it. Unclipped cells need no width rule (the header + // cell's `min-width` floors the column), so leaving them untagged + // keeps the width rules' matched set to one cell per column. const override_width = this._column_sizes.override[key]; if (override_width) { const auto_width = this._column_sizes.auto[key] || 0; @@ -50,8 +54,15 @@ export class RegularBodyViewModel extends ViewModel { if (td.classList.contains("rt-cell-clip") !== clip) { td.classList.toggle("rt-cell-clip", clip); } + + if (clip) { + this._tagColumn(td, key); + } else { + this._untagColumn(td); + } } else { td.classList.remove("rt-cell-clip"); + this._untagColumn(td); } if (metadata.value !== val) { diff --git a/src/ts/thead.ts b/src/ts/thead.ts index f5b3bf0..21d6462 100644 --- a/src/ts/thead.ts +++ b/src/ts/thead.ts @@ -74,6 +74,9 @@ export class RegularHeaderViewModel extends ViewModel { th: HTMLTableCellElement, ): CellMetadataBuilder { const metadata = this._get_or_create_metadata(th); + // Group (non-`rt-autosize`) header cells span columns and must not carry + // a per-column width tag (they are neutralized with `max-width:0`). + this._untagColumn(th); metadata.column_header = column; metadata.value = column_name; return metadata; @@ -90,6 +93,7 @@ export class RegularHeaderViewModel extends ViewModel { metadata.value = column_name; metadata.size_key = Array.isArray(size_key) ? size_key[0] : size_key; if (!Array.isArray(size_key) || size_key.length <= 1) { + this._tagColumn(th, metadata.size_key || 0); const override_width = this._column_sizes.override[metadata.size_key || 0]; const auto_width = @@ -104,6 +108,9 @@ export class RegularHeaderViewModel extends ViewModel { } else { th.classList.remove("rt-cell-clip"); } + } else { + // A leaf header spanning multiple columns has no single width. + this._untagColumn(th); } return metadata; diff --git a/src/ts/view_model.ts b/src/ts/view_model.ts index ab5337c..edc0bcf 100644 --- a/src/ts/view_model.ts +++ b/src/ts/view_model.ts @@ -15,6 +15,12 @@ import { CellMetadata, CellMetadataBuilder, ColumnSizes } from "./types"; // datagrids themselves for each `` export const METADATA_MAP: WeakMap = new WeakMap(); +// Tracks the `size_key` currently encoded in a cell's `rt-col-{size_key}` +// class, so `_tagColumn` can cheaply detect when a pooled cell changes columns +// without a DOM read. Module-level so `thead`/`tbody` models and the event +// model share one tracker. +const COLUMN_TAG_MAP: WeakMap = new WeakMap(); + /****************************************************************************** * * View Model @@ -80,6 +86,45 @@ export class ViewModel { METADATA_MAP.set(td, metadata); } + /** + * Tag a cell with a stable, position-independent `rt-col-{size_key}` class + * identifying its logical column, so generated width rules can target it + * without `:nth-of-type`. Only rewrites the class when the cell actually + * changes columns (horizontal scroll), so vertical scroll adds no churn. + * + * Body cells are tagged only when their column's width must reach them (an + * override clamp on clipped cells) — header cells alone carry the auto + * `min-width` floor, keeping the rule-matched element set small. + */ + _tagColumn(cell: HTMLTableCellElement, size_key: number): void { + const prev = COLUMN_TAG_MAP.get(cell); + if (prev === size_key) { + return; + } + + if (prev !== undefined) { + cell.classList.remove(`rt-col-${prev}`); + } + + cell.classList.add(`rt-col-${size_key}`); + COLUMN_TAG_MAP.set(cell, size_key); + } + + /** + * Remove a cell's `rt-col-{size_key}` column tag — used when a pooled cell + * is (re)drawn as a merged group header (spans columns, must not carry a + * width) or as a body cell that no longer needs its column's clamp. + */ + _untagColumn(cell: HTMLTableCellElement): void { + const prev = COLUMN_TAG_MAP.get(cell); + if (prev === undefined) { + return; + } + + cell.classList.remove(`rt-col-${prev}`); + COLUMN_TAG_MAP.delete(cell); + } + _get_or_create_metadata( td: HTMLTableCellElement | undefined, ): CellMetadataBuilder {