diff --git a/apps/marketing/src/constants/changelog.ts b/apps/marketing/src/constants/changelog.ts index dc601c67d..8bfc45259 100644 --- a/apps/marketing/src/constants/changelog.ts +++ b/apps/marketing/src/constants/changelog.ts @@ -12,7 +12,7 @@ export interface ChangelogEntry { } export const v3_0_0: ChangelogEntry = { - version: "3.0.0", + version: "3.0.1", date: "2026-03-29", title: "Framework Adapters & Column Virtualization", titleLink: "/migrations/v3", @@ -26,8 +26,7 @@ export const v3_0_0: ChangelogEntry = { }, { type: "breaking", - description: - "simple-table-core is now plain JS — no framework components exported", + description: "simple-table-core is now plain JS — no framework components exported", }, { type: "feature", diff --git a/apps/marketing/src/examples/infrastructure/useServerMetricsUpdates.ts b/apps/marketing/src/examples/infrastructure/useServerMetricsUpdates.ts index 804bfe2cb..e1cccfb67 100644 --- a/apps/marketing/src/examples/infrastructure/useServerMetricsUpdates.ts +++ b/apps/marketing/src/examples/infrastructure/useServerMetricsUpdates.ts @@ -1,14 +1,9 @@ import { useEffect, RefObject } from "react"; -import type { TableAPI, Row } from "@simple-table/react"; +import type { TableAPI, Row, CellValue } from "@simple-table/react"; -/** - * Drives “live server metrics” without O(visible rows) concurrent `setTimeout` chains. - * A single interval samples a few visible rows per tick and applies one metric each - * (CPU + sparkline uses two `updateData` calls, at most once per tick). - */ -const TICK_MS = 800; -/** Max rows to touch per tick (each ≤ 2 `updateData` when CPU+history runs). */ -const ROWS_PER_TICK = 4; +/** Slightly slower than frame budget so live updates + scroll rarely pile on one rAF. */ +const TICK_MS = 50; +const ROWS_PER_TICK = 3; type MetricSlot = 0 | 1 | 2 | 3 | 4 | 5 | 6; @@ -23,107 +18,84 @@ function pickRandomSubset(arr: T[], n: number): T[] { return copy.slice(0, Math.min(n, copy.length)); } -function applyOneMetricUpdate(api: TableAPI, server: Row, actualRowIndex: number, slot: MetricSlot) { +function applyRowPatch(api: TableAPI, rowIndex: number, patch: Partial) { + for (const accessor of Object.keys(patch)) { + const newValue = patch[accessor]; + if (newValue === undefined) continue; + api.updateData({ + accessor, + rowIndex, + newValue: newValue as CellValue, + }); + } +} + +function computeMetricPatch(row: Row, slot: MetricSlot): Partial | null { switch (slot) { case 0: { - const currentCpu = server.cpuUsage as number; - if (typeof currentCpu !== "number") return; + const currentCpu = row.cpuUsage as number; + if (typeof currentCpu !== "number") return null; const cpuChange = (Math.random() - 0.5) * 8; const newCpu = Math.min(100, Math.max(0, currentCpu + cpuChange)); const newCpuRounded = Math.round(newCpu * 10) / 10; - api.updateData({ - accessor: "cpuUsage", - rowIndex: actualRowIndex, - newValue: newCpuRounded, - }); - const currentHistory = server.cpuHistory as number[]; + const currentHistory = row.cpuHistory as number[]; if (Array.isArray(currentHistory) && currentHistory.length > 0) { const updatedHistory = [...currentHistory.slice(1), newCpuRounded]; - api.updateData({ - accessor: "cpuHistory", - rowIndex: actualRowIndex, - newValue: updatedHistory, - }); + return { cpuUsage: newCpuRounded, cpuHistory: updatedHistory }; } - break; + return { cpuUsage: newCpuRounded }; } case 1: { - const currentMemory = server.memoryUsage as number; - if (typeof currentMemory !== "number") return; + const currentMemory = row.memoryUsage as number; + if (typeof currentMemory !== "number") return null; const memoryChange = (Math.random() - 0.5) * 5; const newMemory = Math.min(100, Math.max(0, currentMemory + memoryChange)); - api.updateData({ - accessor: "memoryUsage", - rowIndex: actualRowIndex, - newValue: Math.round(newMemory * 10) / 10, - }); - break; + return { memoryUsage: Math.round(newMemory * 10) / 10 }; } case 2: { - const currentNetIn = server.networkIn as number; - if (typeof currentNetIn !== "number") return; + const currentNetIn = row.networkIn as number; + if (typeof currentNetIn !== "number") return null; const netChange = (Math.random() - 0.5) * 100; const newNetIn = Math.max(0, currentNetIn + netChange); - api.updateData({ - accessor: "networkIn", - rowIndex: actualRowIndex, - newValue: Math.round(newNetIn * 100) / 100, - }); - break; + return { networkIn: Math.round(newNetIn * 100) / 100 }; } case 3: { - const currentNetOut = server.networkOut as number; - if (typeof currentNetOut !== "number") return; + const currentNetOut = row.networkOut as number; + if (typeof currentNetOut !== "number") return null; const netChange = (Math.random() - 0.5) * 60; const newNetOut = Math.max(0, currentNetOut + netChange); - api.updateData({ - accessor: "networkOut", - rowIndex: actualRowIndex, - newValue: Math.round(newNetOut * 100) / 100, - }); - break; + return { networkOut: Math.round(newNetOut * 100) / 100 }; } case 4: { - const currentResponseTime = server.responseTime as number; - if (typeof currentResponseTime !== "number") return; + const currentResponseTime = row.responseTime as number; + if (typeof currentResponseTime !== "number") return null; const responseChange = (Math.random() - 0.5) * 100; const newResponseTime = Math.max(10, currentResponseTime + responseChange); - api.updateData({ - accessor: "responseTime", - rowIndex: actualRowIndex, - newValue: Math.round(newResponseTime * 10) / 10, - }); - break; + return { responseTime: Math.round(newResponseTime * 10) / 10 }; } case 5: { - const currentConnections = server.activeConnections as number; - if (typeof currentConnections !== "number") return; + const currentConnections = row.activeConnections as number; + if (typeof currentConnections !== "number") return null; const connectionChange = Math.floor((Math.random() - 0.5) * 500); const newConnections = Math.max(0, currentConnections + connectionChange); - api.updateData({ - accessor: "activeConnections", - rowIndex: actualRowIndex, - newValue: newConnections, - }); - break; + return { activeConnections: newConnections }; } case 6: { - const currentRequests = server.requestsPerSec as number; - if (typeof currentRequests !== "number") return; + const currentRequests = row.requestsPerSec as number; + if (typeof currentRequests !== "number") return null; const requestChange = Math.floor((Math.random() - 0.5) * 2000); const newRequests = Math.max(0, currentRequests + requestChange); - api.updateData({ - accessor: "requestsPerSec", - rowIndex: actualRowIndex, - newValue: newRequests, - }); - break; + return { requestsPerSec: newRequests }; } default: - break; + return null; } } +/** + * Drives “live server metrics” without O(visible rows) concurrent `setTimeout` chains. + * A single interval samples a few visible rows per tick and applies one metric patch each. + */ export function useServerMetricsUpdates(tableRef: RefObject, data: Row[]) { useEffect(() => { let isActive = true; @@ -156,7 +128,10 @@ export function useServerMetricsUpdates(tableRef: RefObject, data: Row usedCpuSparkline = true; } - applyOneMetricUpdate(api, data[idx]!, idx, slot); + const patch = computeMetricPatch(vr.row, slot); + if (patch) { + applyRowPatch(api, idx, patch); + } } }; diff --git a/packages/angular/package.json b/packages/angular/package.json index abdd8f6b8..4413ce75e 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -1,6 +1,6 @@ { "name": "@simple-table/angular", - "version": "3.0.0", + "version": "3.0.1", "main": "dist/cjs/index.js", "module": "dist/index.es.js", "types": "dist/types/angular/src/index.d.ts", diff --git a/packages/core/package.json b/packages/core/package.json index fcea0d8fa..8d77c9a6f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "simple-table-core", - "version": "3.0.0", + "version": "3.0.1", "main": "dist/cjs/index.js", "module": "dist/index.es.js", "types": "dist/src/index.d.ts", diff --git a/packages/core/src/core/SimpleTableVanilla.ts b/packages/core/src/core/SimpleTableVanilla.ts index b81a183b4..8fc43bf92 100644 --- a/packages/core/src/core/SimpleTableVanilla.ts +++ b/packages/core/src/core/SimpleTableVanilla.ts @@ -265,10 +265,6 @@ export class SimpleTableVanilla { infiniteScrollThreshold: 200, }); - this.scrollManager.subscribe(() => { - this.render("scrollManager"); - }); - this.sectionScrollController = new SectionScrollController({ onMainSectionScrollLeft: (scrollLeft) => { const refs = this.domManager.getRefs(); @@ -344,6 +340,16 @@ export class SimpleTableVanilla { } this.setupEventListeners(); + + // DimensionManager defers its first subscriber notification to the next frame + // (ResizeObserver + rAF). Prime row caches only (no DOM) so imperative callers + // (e.g. getVisibleRows right after mount) do not fall back to the full flattened list. + if (this.dimensionManager) { + this.renderOrchestrator.primeLastProcessedResult( + this.getRenderContext(), + this.getRenderState(), + ); + } } private setupEventListeners(): void { diff --git a/packages/core/src/core/api/TableAPIImpl.ts b/packages/core/src/core/api/TableAPIImpl.ts index f4b9514f9..0aa0db069 100644 --- a/packages/core/src/core/api/TableAPIImpl.ts +++ b/packages/core/src/core/api/TableAPIImpl.ts @@ -7,6 +7,7 @@ import SortColumn, { SortDirection } from "../../types/SortColumn"; import { FilterCondition, TableFilterState } from "../../types/FilterTypes"; import { CustomTheme } from "../../types/CustomTheme"; import UpdateDataProps from "../../types/UpdateCellProps"; +import type CellValue from "../../types/CellValue"; import { SetHeaderRenameProps, ExportToCSVProps } from "../../types/TableAPI"; import RowState from "../../types/RowState"; import Cell from "../../types/Cell"; @@ -76,6 +77,40 @@ export class TableAPIImpl { }); }; + /** Coalesce many `updateData` calls in one turn (e.g. live metrics) into one DOM pass. */ + const pendingUpdateDataByKey = new Map(); + let updateDataFlushScheduled = false; + + const flushPendingUpdateData = () => { + updateDataFlushScheduled = false; + if (pendingUpdateDataByKey.size === 0) return; + + let needsFullRender = false; + pendingUpdateDataByKey.forEach((_value, key) => { + if (!context.cellRegistry?.get(key)) { + needsFullRender = true; + } + }); + + if (needsFullRender) { + pendingUpdateDataByKey.clear(); + context.onRender(); + return; + } + + pendingUpdateDataByKey.forEach((value, key) => { + const entry = context.cellRegistry!.get(key)!; + entry.updateContent(value); + }); + pendingUpdateDataByKey.clear(); + }; + + const scheduleUpdateDataFlush = () => { + if (updateDataFlushScheduled) return; + updateDataFlushScheduled = true; + queueMicrotask(flushPendingUpdateData); + }; + return { updateData: (props: UpdateDataProps) => { const { rowIndex, accessor, newValue } = props; @@ -96,12 +131,8 @@ export class TableAPIImpl { ] : [rowIndex]; const key = `${rowIdArray.join("-")}-${accessor}`; - const entry = context.cellRegistry?.get(key); - if (entry) { - entry.updateContent(newValue); - } else { - context.onRender(); - } + pendingUpdateDataByKey.set(key, newValue); + scheduleUpdateDataFlush(); } }, diff --git a/packages/core/src/core/rendering/RenderOrchestrator.ts b/packages/core/src/core/rendering/RenderOrchestrator.ts index 47154c798..6d48bb1a8 100644 --- a/packages/core/src/core/rendering/RenderOrchestrator.ts +++ b/packages/core/src/core/rendering/RenderOrchestrator.ts @@ -3,7 +3,7 @@ import { CustomTheme } from "../../types/CustomTheme"; import HeaderObject, { Accessor } from "../../types/HeaderObject"; import Row from "../../types/Row"; import RowState from "../../types/RowState"; -import { DimensionManager } from "../../managers/DimensionManager"; +import { DimensionManager, type DimensionManagerState } from "../../managers/DimensionManager"; import { ScrollManager } from "../../managers/ScrollManager"; import type { SectionScrollController } from "../../managers/SectionScrollController"; import { SortManager } from "../../managers/SortManager"; @@ -145,36 +145,38 @@ export class RenderOrchestrator { return normalizeHeaderWidths(processedHeaders); } - render( - elements: { - bodyContainer: HTMLElement; - content: HTMLElement; - contentWrapper: HTMLElement; - footerContainer: HTMLElement; - headerContainer: HTMLElement; - rootElement: HTMLElement; - wrapperContainer: HTMLElement; - }, - refs: { - mainBodyRef: { current: HTMLDivElement | null }; - tableBodyContainerRef: { current: HTMLDivElement | null }; - }, + /** + * Warms flattened/processed row caches so imperative APIs (e.g. getVisibleRows) are + * correct before the first ResizeObserver-driven render, without mutating the DOM. + */ + primeLastProcessedResult(context: RenderContext, state: RenderState): void { + const snapshot = this.buildRowModelSnapshot(context, state); + if (!snapshot) return; + this.lastProcessedResult = snapshot.processedResult; + context.rowSelectionManager?.updateConfig({ + tableRows: snapshot.processedResult.currentTableRows, + }); + } + + private buildRowModelSnapshot( context: RenderContext, state: RenderState, - mergedColumnEditorConfig: MergedColumnEditorConfig, - ): void { - // Invalidate caches when headers or rows change (by reference) + ): { + dimensionState: DimensionManagerState; + containerWidth: number; + effectiveHeaders: HeaderObject[]; + calculatedHeaderHeight: number; + maxHeaderDepth: number; + flattenResult: FlattenRowsResult; + processedResult: ProcessRowsResult; + } | null { if (this.lastHeadersRef !== context.headers) { this.invalidateCache("header"); this.invalidateCache("context"); this.lastHeadersRef = context.headers; } - if (!context.dimensionManager) return; - - // Capture horizontal scroll at start so we can reapply after header/body render (DOM updates can reset it) - const savedScrollLeft = - context.mainBodyRef?.current?.scrollLeft ?? context.mainHeaderRef?.current?.scrollLeft ?? 0; + if (!context.dimensionManager) return null; const dimensionState = context.dimensionManager.getState(); @@ -187,8 +189,6 @@ export class RenderOrchestrator { containerWidth, ); - // Calculate pinned section widths from un-scaled headers first so auto-scale - // knows exactly how much space is available for the main section. const { leftWidth: pinnedLeftWidth, rightWidth: pinnedRightWidth } = recalculateAllSectionWidths({ headers: effectiveHeaders, @@ -207,65 +207,17 @@ export class RenderOrchestrator { }); } - const { mainWidth, leftWidth, rightWidth, leftContentWidth, rightContentWidth } = - recalculateAllSectionWidths({ - headers: effectiveHeaders, - containerWidth, - collapsedHeaders: context.collapsedHeaders, - }); - - const mainSectionContainerWidth = containerWidth - leftWidth - rightWidth; - - // Match main: maxHeight overrides height for the container; when maxHeight is set, height prop is ignored - const normalizeHeight = (v: string | number) => (typeof v === "number" ? `${v}px` : v); - const rootStyle = elements.rootElement.style; - // Never assign style.cssText on the root: consumers (e.g. theme builders) set - // --st-* tokens via setProperty; a full cssText replace would wipe them every render. - if (context.config.maxHeight) { - const normalizedMax = normalizeHeight(context.config.maxHeight); - rootStyle.maxHeight = normalizedMax; - rootStyle.height = - dimensionState.contentHeight === undefined ? "auto" : normalizedMax; - } else { - rootStyle.removeProperty("max-height"); - if (context.config.height) { - rootStyle.height = normalizeHeight(context.config.height); - } else { - rootStyle.removeProperty("height"); - } - } - - const { customTheme } = context; - rootStyle.setProperty("--st-main-section-width", `${mainSectionContainerWidth}px`); - rootStyle.setProperty("--st-scrollbar-width", `${state.scrollbarWidth}px`); - rootStyle.setProperty( - "--st-editor-width", - `${context.config.editColumns ? COLUMN_EDIT_WIDTH : 0}px`, - ); - rootStyle.setProperty("--st-border-width", `${customTheme.borderWidth}px`); - rootStyle.setProperty("--st-footer-height", `${customTheme.footerHeight}px`); - - const columnResizing = context.config.columnResizing ?? false; - elements.content.className = `st-content ${columnResizing ? "st-resizeable" : "st-not-resizeable"}`; - elements.content.style.width = context.config.editColumns - ? `calc(100% - ${COLUMN_EDIT_WIDTH}px)` - : "100%"; - let effectiveRows = context.localRows; - // Use sorted rows from SortManager (which already includes filtering) - // The FilterManager updates the SortManager's input rows when filters change if (context.sortManager) { effectiveRows = context.sortManager.getSortedRows(); } else if (context.filterManager) { - // Fallback: if no sort manager but filter manager exists, use filtered rows effectiveRows = context.filterManager.getFilteredRows(); } - // Invalidate body and context cache when effective rows change (includes sorting/filtering) if (this.lastRowsRef !== effectiveRows) { this.invalidateCache("body"); - this.invalidateCache("context"); // Also invalidate context to update sort indicators + this.invalidateCache("context"); this.lastRowsRef = effectiveRows; } @@ -277,11 +229,9 @@ export class RenderOrchestrator { effectiveRows = Array.from({ length: rowsToShow }, () => ({})); } - // Check if we can use cached flattened rows const sortState = context.sortManager?.getState(); const filterState = context.filterManager?.getState(); - // Serialize sort and filter state for cache comparison const sortKey = sortState?.sort ? `${sortState.sort.key.accessor}-${sortState.sort.direction}` : "none"; @@ -303,14 +253,13 @@ export class RenderOrchestrator { let aggregatedRows: Row[]; let quickFilteredRows: Row[]; - let flattenResult: any; + let flattenResult: FlattenRowsResult; if (canUseCache && this.flattenedRowsCache) { aggregatedRows = this.flattenedRowsCache.aggregatedRows; quickFilteredRows = this.flattenedRowsCache.quickFilteredRows; flattenResult = this.flattenedRowsCache.flattenResult; } else { - // SortManager already returns aggregated rows, so only aggregate if no SortManager aggregatedRows = context.sortManager ? effectiveRows : calculateAggregatedRows({ @@ -342,7 +291,6 @@ export class RenderOrchestrator { customTheme: context.customTheme, }); - // Cache the result this.flattenedRowsCache = { aggregatedRows, quickFilteredRows, @@ -392,12 +340,101 @@ export class RenderOrchestrator { enableStickyParents: context.config.enableStickyParents ?? false, rowGrouping: context.config.rowGrouping, }); + + return { + dimensionState, + containerWidth, + effectiveHeaders, + calculatedHeaderHeight, + maxHeaderDepth, + flattenResult, + processedResult, + }; + } + + render( + elements: { + bodyContainer: HTMLElement; + content: HTMLElement; + contentWrapper: HTMLElement; + footerContainer: HTMLElement; + headerContainer: HTMLElement; + rootElement: HTMLElement; + wrapperContainer: HTMLElement; + }, + refs: { + mainBodyRef: { current: HTMLDivElement | null }; + tableBodyContainerRef: { current: HTMLDivElement | null }; + }, + context: RenderContext, + state: RenderState, + mergedColumnEditorConfig: MergedColumnEditorConfig, + ): void { + const savedScrollLeft = + context.mainBodyRef?.current?.scrollLeft ?? context.mainHeaderRef?.current?.scrollLeft ?? 0; + + const snapshot = this.buildRowModelSnapshot(context, state); + if (!snapshot) return; + + const { + dimensionState, + containerWidth, + effectiveHeaders, + calculatedHeaderHeight, + maxHeaderDepth, + flattenResult, + processedResult, + } = snapshot; this.lastProcessedResult = processedResult; context.rowSelectionManager?.updateConfig({ tableRows: processedResult.currentTableRows, }); + const { mainWidth, leftWidth, rightWidth, leftContentWidth, rightContentWidth } = + recalculateAllSectionWidths({ + headers: effectiveHeaders, + containerWidth, + collapsedHeaders: context.collapsedHeaders, + }); + + const mainSectionContainerWidth = containerWidth - leftWidth - rightWidth; + + // Match main: maxHeight overrides height for the container; when maxHeight is set, height prop is ignored + const normalizeHeight = (v: string | number) => (typeof v === "number" ? `${v}px` : v); + const rootStyle = elements.rootElement.style; + // Never assign style.cssText on the root: consumers (e.g. theme builders) set + // --st-* tokens via setProperty; a full cssText replace would wipe them every render. + if (context.config.maxHeight) { + const normalizedMax = normalizeHeight(context.config.maxHeight); + rootStyle.maxHeight = normalizedMax; + rootStyle.height = + dimensionState.contentHeight === undefined ? "auto" : normalizedMax; + } else { + rootStyle.removeProperty("max-height"); + if (context.config.height) { + rootStyle.height = normalizeHeight(context.config.height); + } else { + rootStyle.removeProperty("height"); + } + } + + const { customTheme } = context; + rootStyle.setProperty("--st-main-section-width", `${mainSectionContainerWidth}px`); + rootStyle.setProperty("--st-scrollbar-width", `${state.scrollbarWidth}px`); + rootStyle.setProperty( + "--st-editor-width", + `${context.config.editColumns ? COLUMN_EDIT_WIDTH : 0}px`, + ); + rootStyle.setProperty("--st-border-width", `${customTheme.borderWidth}px`); + rootStyle.setProperty("--st-footer-height", `${customTheme.footerHeight}px`); + + const columnResizing = context.config.columnResizing ?? false; + elements.content.className = `st-content ${columnResizing ? "st-resizeable" : "st-not-resizeable"}`; + elements.content.style.width = context.config.editColumns + ? `calc(100% - ${COLUMN_EDIT_WIDTH}px)` + : "100%"; + this.renderHeader( elements.headerContainer, calculatedHeaderHeight, diff --git a/packages/core/src/core/rendering/SectionRenderer.ts b/packages/core/src/core/rendering/SectionRenderer.ts index ad18e8de2..b841a760e 100644 --- a/packages/core/src/core/rendering/SectionRenderer.ts +++ b/packages/core/src/core/rendering/SectionRenderer.ts @@ -935,6 +935,9 @@ export class SectionRenderer { }); } else if (type === "context") { this.contextCache.clear(); + // Recompute absolute header layout from current effectiveHeaders; otherwise + // cached AbsoluteCell.header refs drift from live objects (sort/resize bug). + this.headerCellsCache.clear(); // Clear header rendered elements so sort indicators etc. update. // Do NOT clear body rendered elements: renderBodyCells will update existing cells // in place (e.g. selection classes, expand icon state) so expand icon can animate. diff --git a/packages/examples/angular/src/demos/infrastructure/infrastructure-demo.component.ts b/packages/examples/angular/src/demos/infrastructure/infrastructure-demo.component.ts index fe5d9d2ca..6f60f0017 100644 --- a/packages/examples/angular/src/demos/infrastructure/infrastructure-demo.component.ts +++ b/packages/examples/angular/src/demos/infrastructure/infrastructure-demo.component.ts @@ -1,10 +1,129 @@ import { Component, Input, ViewChild, AfterViewInit, OnDestroy } from "@angular/core"; import { SimpleTableComponent, mapToAngularHeaderObjects } from "@simple-table/angular"; -import type { AngularHeaderObject, CellRenderer, Row, Theme, ValueGetterProps } from "@simple-table/angular"; -import { infrastructureData, INFRA_UPDATE_CONFIG, getInfraMetricColorStyles, getInfraStatusColors } from "./infrastructure.demo-data"; +import type { + AngularHeaderObject, + CellRenderer, + CellValue, + Row, + TableAPI, + Theme, + ValueGetterProps, +} from "@simple-table/angular"; +import { infrastructureData, getInfraMetricColorStyles, getInfraStatusColors } from "./infrastructure.demo-data"; import type { InfrastructureServer } from "./infrastructure.demo-data"; import "@simple-table/angular/styles.css"; +const INFRA_TICK_MS = 20; +const INFRA_ROWS_PER_TICK = 4; +type InfraMetricSlot = 0 | 1 | 2 | 3 | 4 | 5 | 6; + +function infraPickRandomSubset(arr: T[], n: number): T[] { + const copy = [...arr]; + for (let i = copy.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + const t = copy[i]!; + copy[i] = copy[j]!; + copy[j] = t; + } + return copy.slice(0, Math.min(n, copy.length)); +} + +function infraApplyRowPatch(api: TableAPI, rowIndex: number, patch: Partial) { + for (const accessor of Object.keys(patch)) { + const newValue = patch[accessor]; + if (newValue === undefined) continue; + api.updateData({ accessor, rowIndex, newValue: newValue as CellValue }); + } +} + +function infraComputeMetricPatch(row: Row, slot: InfraMetricSlot): Partial | null { + switch (slot) { + case 0: { + const currentCpu = row.cpuUsage as number; + if (typeof currentCpu !== "number") return null; + const cpuChange = (Math.random() - 0.5) * 8; + const newCpu = Math.min(100, Math.max(0, currentCpu + cpuChange)); + const newCpuRounded = Math.round(newCpu * 10) / 10; + const currentHistory = row.cpuHistory as number[]; + if (Array.isArray(currentHistory) && currentHistory.length > 0) { + return { cpuUsage: newCpuRounded, cpuHistory: [...currentHistory.slice(1), newCpuRounded] }; + } + return { cpuUsage: newCpuRounded }; + } + case 1: { + const currentMemory = row.memoryUsage as number; + if (typeof currentMemory !== "number") return null; + const memoryChange = (Math.random() - 0.5) * 5; + const newMemory = Math.min(100, Math.max(0, currentMemory + memoryChange)); + return { memoryUsage: Math.round(newMemory * 10) / 10 }; + } + case 2: { + const currentNetIn = row.networkIn as number; + if (typeof currentNetIn !== "number") return null; + const netChange = (Math.random() - 0.5) * 100; + return { networkIn: Math.round(Math.max(0, currentNetIn + netChange) * 100) / 100 }; + } + case 3: { + const currentNetOut = row.networkOut as number; + if (typeof currentNetOut !== "number") return null; + const netChange = (Math.random() - 0.5) * 60; + return { networkOut: Math.round(Math.max(0, currentNetOut + netChange) * 100) / 100 }; + } + case 4: { + const currentResponseTime = row.responseTime as number; + if (typeof currentResponseTime !== "number") return null; + const responseChange = (Math.random() - 0.5) * 100; + return { responseTime: Math.round(Math.max(10, currentResponseTime + responseChange) * 10) / 10 }; + } + case 5: { + const currentConnections = row.activeConnections as number; + if (typeof currentConnections !== "number") return null; + const connectionChange = Math.floor((Math.random() - 0.5) * 500); + return { activeConnections: Math.max(0, currentConnections + connectionChange) }; + } + case 6: { + const currentRequests = row.requestsPerSec as number; + if (typeof currentRequests !== "number") return null; + const requestChange = Math.floor((Math.random() - 0.5) * 2000); + return { requestsPerSec: Math.max(0, currentRequests + requestChange) }; + } + default: + return null; + } +} + +function startInfraDemoLiveUpdates(getApi: () => TableAPI | null | undefined, rows: Row[]): () => void { + let isActive = true; + const idToIndex = new Map(); + for (let i = 0; i < rows.length; i++) { + idToIndex.set(String(rows[i]!.id), i); + } + const tick = () => { + if (!isActive) return; + const api = getApi(); + if (!api) return; + const visible = api.getVisibleRows(); + if (!visible.length) return; + const picks = infraPickRandomSubset(visible, INFRA_ROWS_PER_TICK); + let usedCpuSparkline = false; + for (const vr of picks) { + const idx = idToIndex.get(String(vr.row.id)); + if (idx === undefined) continue; + let slot = Math.floor(Math.random() * 7) as InfraMetricSlot; + if (slot === 0 && usedCpuSparkline) slot = (1 + Math.floor(Math.random() * 6)) as InfraMetricSlot; + if (slot === 0) usedCpuSparkline = true; + const patch = infraComputeMetricPatch(vr.row, slot); + if (patch) infraApplyRowPatch(api, idx, patch); + } + }; + tick(); + const intervalId = setInterval(tick, INFRA_TICK_MS); + return () => { + isActive = false; + clearInterval(intervalId); + }; +} + function getHeaders(currentTheme?: Theme): AngularHeaderObject[] { const t = currentTheme || "light"; @@ -129,84 +248,7 @@ export class InfrastructureDemoComponent implements AfterViewInit, OnDestroy { this.headers.splice(0, this.headers.length, ...getHeaders(this.theme)); - const currentData = JSON.parse(JSON.stringify(this.rows)); - const timerMap = new Map>(); - let isActive = true; - - const createRowTimer = (rowId: string) => { - const scheduleUpdate = () => { - if (!isActive) return; - const interval = INFRA_UPDATE_CONFIG.minInterval + Math.random() * (INFRA_UPDATE_CONFIG.maxInterval - INFRA_UPDATE_CONFIG.minInterval); - const timerId = setTimeout(() => { - if (!isActive) return; - const currentApi = this.tableRef?.getAPI(); - if (!currentApi) return; - const idx = currentData.findIndex((r: Row) => r.id === rowId); - if (idx === -1) return; - const server = currentData[idx] as unknown as InfrastructureServer; - - const cpu = server.cpuUsage; - if (typeof cpu === "number") { - const newCpu = Math.round(Math.min(100, Math.max(0, cpu + (Math.random() - 0.5) * 8)) * 10) / 10; - currentData[idx].cpuUsage = newCpu; - currentApi.updateData({ accessor: "cpuUsage", rowIndex: idx, newValue: newCpu }); - const hist = server.cpuHistory; - if (Array.isArray(hist) && hist.length > 0) { - const updated = [...hist.slice(1), newCpu]; - currentData[idx].cpuHistory = updated; - currentApi.updateData({ accessor: "cpuHistory", rowIndex: idx, newValue: updated }); - } - } - if (Math.random() < 0.4) { - const mem = server.memoryUsage; - if (typeof mem === "number") { - const n = Math.round(Math.min(100, Math.max(0, mem + (Math.random() - 0.5) * 5)) * 10) / 10; - currentData[idx].memoryUsage = n; - currentApi.updateData({ accessor: "memoryUsage", rowIndex: idx, newValue: n }); - } - } - if (Math.random() < 0.5) { - const rt = server.responseTime; - if (typeof rt === "number") { - const n = Math.round(Math.max(10, rt + (Math.random() - 0.5) * 100) * 10) / 10; - currentData[idx].responseTime = n; - currentApi.updateData({ accessor: "responseTime", rowIndex: idx, newValue: n }); - } - } - scheduleUpdate(); - }, interval); - timerMap.set(rowId, timerId); - }; - scheduleUpdate(); - }; - - const syncTimers = () => { - const currentApi = this.tableRef?.getAPI(); - if (!currentApi) return; - const visibleRows = currentApi.getVisibleRows(); - const visibleIds = new Set(visibleRows.map((vr) => String(vr.row.id))); - timerMap.forEach((tid, rid) => { - if (!visibleIds.has(rid)) { - clearTimeout(tid); - timerMap.delete(rid); - } - }); - visibleRows.forEach((vr) => { - const rid = String(vr.row.id); - if (!timerMap.has(rid)) createRowTimer(rid); - }); - }; - - const syncInt = setInterval(syncTimers, 500); - - this.cleanupFn = () => { - isActive = false; - clearInterval(syncInt); - timerMap.forEach((t) => clearTimeout(t)); - timerMap.clear(); - }; - - syncTimers(); + this.cleanupFn = startInfraDemoLiveUpdates(() => this.tableRef?.getAPI() ?? null, this.rows); } ngOnDestroy(): void { diff --git a/packages/examples/angular/src/demos/infrastructure/infrastructure.demo-data.ts b/packages/examples/angular/src/demos/infrastructure/infrastructure.demo-data.ts index 563899352..28587ef86 100644 --- a/packages/examples/angular/src/demos/infrastructure/infrastructure.demo-data.ts +++ b/packages/examples/angular/src/demos/infrastructure/infrastructure.demo-data.ts @@ -187,11 +187,6 @@ export const infrastructureHeaders: HeaderObject[] = [ }, ]; -export const INFRA_UPDATE_CONFIG = { - minInterval: 300, - maxInterval: 1000, -}; - export function getInfraMetricColorStyles( value: number, theme: string, diff --git a/packages/examples/react/src/demos/infrastructure/InfrastructureDemo.tsx b/packages/examples/react/src/demos/infrastructure/InfrastructureDemo.tsx index c244b0eaa..c776c626f 100644 --- a/packages/examples/react/src/demos/infrastructure/InfrastructureDemo.tsx +++ b/packages/examples/react/src/demos/infrastructure/InfrastructureDemo.tsx @@ -1,10 +1,121 @@ import { useRef, useEffect, useState } from "react"; import { SimpleTable } from "@simple-table/react"; -import type { Theme, TableAPI, ReactHeaderObject, CellRendererProps } from "@simple-table/react"; -import { infrastructureData, INFRA_UPDATE_CONFIG, getInfraMetricColorStyles, getInfraStatusColors } from "./infrastructure.demo-data"; +import type { Theme, TableAPI, Row, CellValue, ReactHeaderObject, CellRendererProps } from "@simple-table/react"; +import { infrastructureData, getInfraMetricColorStyles, getInfraStatusColors } from "./infrastructure.demo-data"; import type { InfrastructureServer } from "./infrastructure.demo-data"; import "@simple-table/react/styles.css"; +const INFRA_TICK_MS = 20; +const INFRA_ROWS_PER_TICK = 4; +type InfraMetricSlot = 0 | 1 | 2 | 3 | 4 | 5 | 6; + +function infraPickRandomSubset(arr: T[], n: number): T[] { + const copy = [...arr]; + for (let i = copy.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + const t = copy[i]!; + copy[i] = copy[j]!; + copy[j] = t; + } + return copy.slice(0, Math.min(n, copy.length)); +} + +function infraApplyRowPatch(api: TableAPI, rowIndex: number, patch: Partial) { + for (const accessor of Object.keys(patch)) { + const newValue = patch[accessor]; + if (newValue === undefined) continue; + api.updateData({ accessor, rowIndex, newValue: newValue as CellValue }); + } +} + +function infraComputeMetricPatch(row: Row, slot: InfraMetricSlot): Partial | null { + switch (slot) { + case 0: { + const currentCpu = row.cpuUsage as number; + if (typeof currentCpu !== "number") return null; + const cpuChange = (Math.random() - 0.5) * 8; + const newCpu = Math.min(100, Math.max(0, currentCpu + cpuChange)); + const newCpuRounded = Math.round(newCpu * 10) / 10; + const currentHistory = row.cpuHistory as number[]; + if (Array.isArray(currentHistory) && currentHistory.length > 0) { + return { cpuUsage: newCpuRounded, cpuHistory: [...currentHistory.slice(1), newCpuRounded] }; + } + return { cpuUsage: newCpuRounded }; + } + case 1: { + const currentMemory = row.memoryUsage as number; + if (typeof currentMemory !== "number") return null; + const memoryChange = (Math.random() - 0.5) * 5; + const newMemory = Math.min(100, Math.max(0, currentMemory + memoryChange)); + return { memoryUsage: Math.round(newMemory * 10) / 10 }; + } + case 2: { + const currentNetIn = row.networkIn as number; + if (typeof currentNetIn !== "number") return null; + const netChange = (Math.random() - 0.5) * 100; + return { networkIn: Math.round(Math.max(0, currentNetIn + netChange) * 100) / 100 }; + } + case 3: { + const currentNetOut = row.networkOut as number; + if (typeof currentNetOut !== "number") return null; + const netChange = (Math.random() - 0.5) * 60; + return { networkOut: Math.round(Math.max(0, currentNetOut + netChange) * 100) / 100 }; + } + case 4: { + const currentResponseTime = row.responseTime as number; + if (typeof currentResponseTime !== "number") return null; + const responseChange = (Math.random() - 0.5) * 100; + return { responseTime: Math.round(Math.max(10, currentResponseTime + responseChange) * 10) / 10 }; + } + case 5: { + const currentConnections = row.activeConnections as number; + if (typeof currentConnections !== "number") return null; + const connectionChange = Math.floor((Math.random() - 0.5) * 500); + return { activeConnections: Math.max(0, currentConnections + connectionChange) }; + } + case 6: { + const currentRequests = row.requestsPerSec as number; + if (typeof currentRequests !== "number") return null; + const requestChange = Math.floor((Math.random() - 0.5) * 2000); + return { requestsPerSec: Math.max(0, currentRequests + requestChange) }; + } + default: + return null; + } +} + +function startInfraDemoLiveUpdates(getApi: () => TableAPI | null | undefined, rows: Row[]): () => void { + let isActive = true; + const idToIndex = new Map(); + for (let i = 0; i < rows.length; i++) { + idToIndex.set(String(rows[i]!.id), i); + } + const tick = () => { + if (!isActive) return; + const api = getApi(); + if (!api) return; + const visible = api.getVisibleRows(); + if (!visible.length) return; + const picks = infraPickRandomSubset(visible, INFRA_ROWS_PER_TICK); + let usedCpuSparkline = false; + for (const vr of picks) { + const idx = idToIndex.get(String(vr.row.id)); + if (idx === undefined) continue; + let slot = Math.floor(Math.random() * 7) as InfraMetricSlot; + if (slot === 0 && usedCpuSparkline) slot = (1 + Math.floor(Math.random() * 6)) as InfraMetricSlot; + if (slot === 0) usedCpuSparkline = true; + const patch = infraComputeMetricPatch(vr.row, slot); + if (patch) infraApplyRowPatch(api, idx, patch); + } + }; + tick(); + const intervalId = setInterval(tick, INFRA_TICK_MS); + return () => { + isActive = false; + clearInterval(intervalId); + }; +} + function getHeaders(currentTheme?: Theme): ReactHeaderObject[] { const t = currentTheme || "light"; return [ @@ -51,50 +162,8 @@ const InfrastructureDemo = ({ height = "400px", theme }: { height?: string | num }, []); useEffect(() => { - const currentData: InfrastructureServer[] = JSON.parse(JSON.stringify(data)); - const timerMap = new Map>(); - let isActive = true; - - const createRowTimer = (rowId: number) => { - const scheduleUpdate = () => { - if (!isActive) return; - const interval = INFRA_UPDATE_CONFIG.minInterval + Math.random() * (INFRA_UPDATE_CONFIG.maxInterval - INFRA_UPDATE_CONFIG.minInterval); - const timerId = setTimeout(() => { - if (!isActive || !tableRef.current) return; - const idx = currentData.findIndex((r) => r.id === rowId); - if (idx === -1) return; - const server = currentData[idx]; - - const newCpu = Math.round(Math.min(100, Math.max(0, server.cpuUsage + (Math.random() - 0.5) * 8)) * 10) / 10; - currentData[idx].cpuUsage = newCpu; - tableRef.current?.updateData({ accessor: "cpuUsage", rowIndex: idx, newValue: newCpu }); - if (server.cpuHistory.length > 0) { - const updated = [...server.cpuHistory.slice(1), newCpu]; - currentData[idx].cpuHistory = updated; - tableRef.current?.updateData({ accessor: "cpuHistory", rowIndex: idx, newValue: updated }); - } - - if (Math.random() < 0.4) { const n = Math.round(Math.min(100, Math.max(0, server.memoryUsage + (Math.random() - 0.5) * 5)) * 10) / 10; currentData[idx].memoryUsage = n; tableRef.current?.updateData({ accessor: "memoryUsage", rowIndex: idx, newValue: n }); } - if (Math.random() < 0.5) { const n = Math.round(Math.max(10, server.responseTime + (Math.random() - 0.5) * 100) * 10) / 10; currentData[idx].responseTime = n; tableRef.current?.updateData({ accessor: "responseTime", rowIndex: idx, newValue: n }); } - scheduleUpdate(); - }, interval); - timerMap.set(rowId, timerId); - }; - scheduleUpdate(); - }; - - const syncTimers = () => { - if (!tableRef.current) return; - const visibleRows = tableRef.current.getVisibleRows(); - const visibleIds = new Set(visibleRows.map((vr) => vr.row.id as number)); - timerMap.forEach((tid, rid) => { if (!visibleIds.has(rid)) { clearTimeout(tid); timerMap.delete(rid); } }); - visibleRows.forEach((vr) => { const rid = vr.row.id as number; if (!timerMap.has(rid)) createRowTimer(rid); }); - }; - - syncTimers(); - const syncInterval = setInterval(syncTimers, 500); - return () => { isActive = false; clearInterval(syncInterval); timerMap.forEach((t) => clearTimeout(t)); timerMap.clear(); }; - }, [data]); + return startInfraDemoLiveUpdates(() => tableRef.current, data); + }, [tableRef, data]); return ( (arr: T[], n: number): T[] { + const copy = [...arr]; + for (let i = copy.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + const t = copy[i]!; + copy[i] = copy[j]!; + copy[j] = t; + } + return copy.slice(0, Math.min(n, copy.length)); +} + +function infraApplyRowPatch(api: TableAPI, rowIndex: number, patch: Partial) { + for (const accessor of Object.keys(patch)) { + const newValue = patch[accessor]; + if (newValue === undefined) continue; + api.updateData({ accessor, rowIndex, newValue: newValue as CellValue }); + } +} + +function infraComputeMetricPatch(row: Row, slot: InfraMetricSlot): Partial | null { + switch (slot) { + case 0: { + const currentCpu = row.cpuUsage as number; + if (typeof currentCpu !== "number") return null; + const cpuChange = (Math.random() - 0.5) * 8; + const newCpu = Math.min(100, Math.max(0, currentCpu + cpuChange)); + const newCpuRounded = Math.round(newCpu * 10) / 10; + const currentHistory = row.cpuHistory as number[]; + if (Array.isArray(currentHistory) && currentHistory.length > 0) { + return { cpuUsage: newCpuRounded, cpuHistory: [...currentHistory.slice(1), newCpuRounded] }; + } + return { cpuUsage: newCpuRounded }; + } + case 1: { + const currentMemory = row.memoryUsage as number; + if (typeof currentMemory !== "number") return null; + const memoryChange = (Math.random() - 0.5) * 5; + const newMemory = Math.min(100, Math.max(0, currentMemory + memoryChange)); + return { memoryUsage: Math.round(newMemory * 10) / 10 }; + } + case 2: { + const currentNetIn = row.networkIn as number; + if (typeof currentNetIn !== "number") return null; + const netChange = (Math.random() - 0.5) * 100; + return { networkIn: Math.round(Math.max(0, currentNetIn + netChange) * 100) / 100 }; + } + case 3: { + const currentNetOut = row.networkOut as number; + if (typeof currentNetOut !== "number") return null; + const netChange = (Math.random() - 0.5) * 60; + return { networkOut: Math.round(Math.max(0, currentNetOut + netChange) * 100) / 100 }; + } + case 4: { + const currentResponseTime = row.responseTime as number; + if (typeof currentResponseTime !== "number") return null; + const responseChange = (Math.random() - 0.5) * 100; + return { responseTime: Math.round(Math.max(10, currentResponseTime + responseChange) * 10) / 10 }; + } + case 5: { + const currentConnections = row.activeConnections as number; + if (typeof currentConnections !== "number") return null; + const connectionChange = Math.floor((Math.random() - 0.5) * 500); + return { activeConnections: Math.max(0, currentConnections + connectionChange) }; + } + case 6: { + const currentRequests = row.requestsPerSec as number; + if (typeof currentRequests !== "number") return null; + const requestChange = Math.floor((Math.random() - 0.5) * 2000); + return { requestsPerSec: Math.max(0, currentRequests + requestChange) }; + } + default: + return null; + } +} + +function startInfraDemoLiveUpdates(getApi: () => TableAPI | null | undefined, rows: Row[]): () => void { + let isActive = true; + const idToIndex = new Map(); + for (let i = 0; i < rows.length; i++) { + idToIndex.set(String(rows[i]!.id), i); + } + const tick = () => { + if (!isActive) return; + const api = getApi(); + if (!api) return; + const visible = api.getVisibleRows(); + if (!visible.length) return; + const picks = infraPickRandomSubset(visible, INFRA_ROWS_PER_TICK); + let usedCpuSparkline = false; + for (const vr of picks) { + const idx = idToIndex.get(String(vr.row.id)); + if (idx === undefined) continue; + let slot = Math.floor(Math.random() * 7) as InfraMetricSlot; + if (slot === 0 && usedCpuSparkline) slot = (1 + Math.floor(Math.random() * 6)) as InfraMetricSlot; + if (slot === 0) usedCpuSparkline = true; + const patch = infraComputeMetricPatch(vr.row, slot); + if (patch) infraApplyRowPatch(api, idx, patch); + } + }; + tick(); + const intervalId = setInterval(tick, INFRA_TICK_MS); + return () => { + isActive = false; + clearInterval(intervalId); + }; +} + function getHeaders(currentTheme?: Theme): SolidHeaderObject[] { const t = currentTheme || "light"; return [ @@ -57,53 +163,7 @@ export default function InfrastructureDemo(props: { height?: string | number; th }); onMount(() => { - const currentData = JSON.parse(JSON.stringify(data)); - const timerMap = new Map>(); - let isActive = true; - - const createRowTimer = (rowId: string) => { - const scheduleUpdate = () => { - if (!isActive) return; - const interval = INFRA_UPDATE_CONFIG.minInterval + Math.random() * (INFRA_UPDATE_CONFIG.maxInterval - INFRA_UPDATE_CONFIG.minInterval); - const timerId = setTimeout(() => { - if (!isActive || !tableRef) return; - const idx = currentData.findIndex((r: Row) => r.id === rowId); - if (idx === -1) return; - const server = currentData[idx] as unknown as InfrastructureServer; - - const cpu = server.cpuUsage; - if (typeof cpu === "number") { - const newCpu = Math.round(Math.min(100, Math.max(0, cpu + (Math.random() - 0.5) * 8)) * 10) / 10; - currentData[idx].cpuUsage = newCpu; - tableRef.updateData({ accessor: "cpuUsage", rowIndex: idx, newValue: newCpu }); - const hist = server.cpuHistory; - if (Array.isArray(hist) && hist.length > 0) { - const updated = [...hist.slice(1), newCpu]; - currentData[idx].cpuHistory = updated; - tableRef.updateData({ accessor: "cpuHistory", rowIndex: idx, newValue: updated }); - } - } - if (Math.random() < 0.4) { const mem = server.memoryUsage; if (typeof mem === "number") { const n = Math.round(Math.min(100, Math.max(0, mem + (Math.random() - 0.5) * 5)) * 10) / 10; currentData[idx].memoryUsage = n; tableRef.updateData({ accessor: "memoryUsage", rowIndex: idx, newValue: n }); } } - if (Math.random() < 0.5) { const rt = server.responseTime; if (typeof rt === "number") { const n = Math.round(Math.max(10, rt + (Math.random() - 0.5) * 100) * 10) / 10; currentData[idx].responseTime = n; tableRef.updateData({ accessor: "responseTime", rowIndex: idx, newValue: n }); } } - scheduleUpdate(); - }, interval); - timerMap.set(rowId, timerId); - }; - scheduleUpdate(); - }; - - const syncTimers = () => { - if (!tableRef) return; - const visibleRows = tableRef.getVisibleRows(); - const visibleIds = new Set(visibleRows.map((vr) => String(vr.row.id))); - timerMap.forEach((tid, rid) => { if (!visibleIds.has(rid)) { clearTimeout(tid); timerMap.delete(rid); } }); - visibleRows.forEach((vr) => { const rid = String(vr.row.id); if (!timerMap.has(rid)) createRowTimer(rid); }); - }; - - syncTimers(); - const syncInterval = setInterval(syncTimers, 500); - - cleanupFn = () => { isActive = false; clearInterval(syncInterval); timerMap.forEach((t) => clearTimeout(t)); timerMap.clear(); }; + cleanupFn = startInfraDemoLiveUpdates(() => tableRef, data); }); onCleanup(() => cleanupFn?.()); diff --git a/packages/examples/solid/src/demos/infrastructure/infrastructure.demo-data.ts b/packages/examples/solid/src/demos/infrastructure/infrastructure.demo-data.ts index 0c1a78a9e..dd7ecdb1c 100644 --- a/packages/examples/solid/src/demos/infrastructure/infrastructure.demo-data.ts +++ b/packages/examples/solid/src/demos/infrastructure/infrastructure.demo-data.ts @@ -187,11 +187,6 @@ export const infrastructureHeaders: HeaderObject[] = [ }, ]; -export const INFRA_UPDATE_CONFIG = { - minInterval: 300, - maxInterval: 1000, -}; - export function getInfraMetricColorStyles( value: number, theme: string, diff --git a/packages/examples/svelte/src/demos/infrastructure/InfrastructureDemo.svelte b/packages/examples/svelte/src/demos/infrastructure/InfrastructureDemo.svelte index 496c7243d..ca543fc29 100644 --- a/packages/examples/svelte/src/demos/infrastructure/InfrastructureDemo.svelte +++ b/packages/examples/svelte/src/demos/infrastructure/InfrastructureDemo.svelte @@ -1,19 +1,124 @@ @@ -195,7 +224,7 @@ defaultHeaders={getHeaders(theme)} editColumns={true} {height} - rows={data} + rows={infrastructureData} selectableCells={true} {theme} /> diff --git a/packages/examples/svelte/src/demos/infrastructure/infrastructure.demo-data.ts b/packages/examples/svelte/src/demos/infrastructure/infrastructure.demo-data.ts index 13afc2c9d..93ce836f7 100644 --- a/packages/examples/svelte/src/demos/infrastructure/infrastructure.demo-data.ts +++ b/packages/examples/svelte/src/demos/infrastructure/infrastructure.demo-data.ts @@ -187,11 +187,6 @@ export const infrastructureHeaders: HeaderObject[] = [ }, ]; -export const INFRA_UPDATE_CONFIG = { - minInterval: 300, - maxInterval: 1000, -}; - export function getInfraMetricColorStyles( value: number, theme: string, diff --git a/packages/examples/vanilla/src/demos/infrastructure/InfrastructureDemo.ts b/packages/examples/vanilla/src/demos/infrastructure/InfrastructureDemo.ts index 9b0ba00ba..ef76e37e7 100644 --- a/packages/examples/vanilla/src/demos/infrastructure/InfrastructureDemo.ts +++ b/packages/examples/vanilla/src/demos/infrastructure/InfrastructureDemo.ts @@ -1,14 +1,120 @@ import { SimpleTableVanilla } from "simple-table-core"; -import type { Theme, HeaderObject, CellRenderer, Row } from "simple-table-core"; -import { - infrastructureData, - INFRA_UPDATE_CONFIG, - getInfraMetricColorStyles, - getInfraStatusColors, -} from "./infrastructure.demo-data"; +import type { Theme, HeaderObject, CellRenderer, TableAPI, Row, CellValue } from "simple-table-core"; +import { infrastructureData, getInfraMetricColorStyles, getInfraStatusColors } from "./infrastructure.demo-data"; import type { InfrastructureServer } from "./infrastructure.demo-data"; import "simple-table-core/styles.css"; +const INFRA_TICK_MS = 20; +const INFRA_ROWS_PER_TICK = 4; +type InfraMetricSlot = 0 | 1 | 2 | 3 | 4 | 5 | 6; + +function infraPickRandomSubset(arr: T[], n: number): T[] { + const copy = [...arr]; + for (let i = copy.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + const t = copy[i]!; + copy[i] = copy[j]!; + copy[j] = t; + } + return copy.slice(0, Math.min(n, copy.length)); +} + +function infraApplyRowPatch(api: TableAPI, rowIndex: number, patch: Partial) { + for (const accessor of Object.keys(patch)) { + const newValue = patch[accessor]; + if (newValue === undefined) continue; + api.updateData({ accessor, rowIndex, newValue: newValue as CellValue }); + } +} + +function infraComputeMetricPatch(row: Row, slot: InfraMetricSlot): Partial | null { + switch (slot) { + case 0: { + const currentCpu = row.cpuUsage as number; + if (typeof currentCpu !== "number") return null; + const cpuChange = (Math.random() - 0.5) * 8; + const newCpu = Math.min(100, Math.max(0, currentCpu + cpuChange)); + const newCpuRounded = Math.round(newCpu * 10) / 10; + const currentHistory = row.cpuHistory as number[]; + if (Array.isArray(currentHistory) && currentHistory.length > 0) { + return { cpuUsage: newCpuRounded, cpuHistory: [...currentHistory.slice(1), newCpuRounded] }; + } + return { cpuUsage: newCpuRounded }; + } + case 1: { + const currentMemory = row.memoryUsage as number; + if (typeof currentMemory !== "number") return null; + const memoryChange = (Math.random() - 0.5) * 5; + const newMemory = Math.min(100, Math.max(0, currentMemory + memoryChange)); + return { memoryUsage: Math.round(newMemory * 10) / 10 }; + } + case 2: { + const currentNetIn = row.networkIn as number; + if (typeof currentNetIn !== "number") return null; + const netChange = (Math.random() - 0.5) * 100; + return { networkIn: Math.round(Math.max(0, currentNetIn + netChange) * 100) / 100 }; + } + case 3: { + const currentNetOut = row.networkOut as number; + if (typeof currentNetOut !== "number") return null; + const netChange = (Math.random() - 0.5) * 60; + return { networkOut: Math.round(Math.max(0, currentNetOut + netChange) * 100) / 100 }; + } + case 4: { + const currentResponseTime = row.responseTime as number; + if (typeof currentResponseTime !== "number") return null; + const responseChange = (Math.random() - 0.5) * 100; + return { responseTime: Math.round(Math.max(10, currentResponseTime + responseChange) * 10) / 10 }; + } + case 5: { + const currentConnections = row.activeConnections as number; + if (typeof currentConnections !== "number") return null; + const connectionChange = Math.floor((Math.random() - 0.5) * 500); + return { activeConnections: Math.max(0, currentConnections + connectionChange) }; + } + case 6: { + const currentRequests = row.requestsPerSec as number; + if (typeof currentRequests !== "number") return null; + const requestChange = Math.floor((Math.random() - 0.5) * 2000); + return { requestsPerSec: Math.max(0, currentRequests + requestChange) }; + } + default: + return null; + } +} + +function startInfraDemoLiveUpdates(getApi: () => TableAPI | null | undefined, rows: Row[]): () => void { + let isActive = true; + const idToIndex = new Map(); + for (let i = 0; i < rows.length; i++) { + idToIndex.set(String(rows[i]!.id), i); + } + const tick = () => { + if (!isActive) return; + const api = getApi(); + if (!api) return; + const visible = api.getVisibleRows(); + if (!visible.length) return; + const picks = infraPickRandomSubset(visible, INFRA_ROWS_PER_TICK); + let usedCpuSparkline = false; + for (const vr of picks) { + const idx = idToIndex.get(String(vr.row.id)); + if (idx === undefined) continue; + let slot = Math.floor(Math.random() * 7) as InfraMetricSlot; + if (slot === 0 && usedCpuSparkline) slot = (1 + Math.floor(Math.random() * 6)) as InfraMetricSlot; + if (slot === 0) usedCpuSparkline = true; + const patch = infraComputeMetricPatch(vr.row, slot); + if (patch) infraApplyRowPatch(api, idx, patch); + } + }; + tick(); + const intervalId = setInterval(tick, INFRA_TICK_MS); + return () => { + isActive = false; + clearInterval(intervalId); + }; +} + function getHeaders(currentTheme?: Theme): HeaderObject[] { const t = currentTheme || "light"; @@ -104,9 +210,7 @@ export function renderInfrastructureDemo( options?: { height?: string | number; theme?: Theme }, ): SimpleTableVanilla { const data = infrastructureData; - const currentData = JSON.parse(JSON.stringify(data)); - const timerMap = new Map>(); - let isActive = true; + let stopLiveUpdates: (() => void) | undefined; const table = new SimpleTableVanilla(container, { autoExpandColumns: true, @@ -120,83 +224,16 @@ export function renderInfrastructureDemo( theme: options?.theme, }); - const createRowTimer = (rowId: string) => { - const scheduleUpdate = () => { - if (!isActive) return; - const interval = INFRA_UPDATE_CONFIG.minInterval + Math.random() * (INFRA_UPDATE_CONFIG.maxInterval - INFRA_UPDATE_CONFIG.minInterval); - const timerId = setTimeout(() => { - if (!isActive) return; - const api = table.getAPI(); - const idx = currentData.findIndex((r: Row) => r.id === rowId); - if (idx === -1) return; - const d = currentData[idx] as unknown as InfrastructureServer; - - const cpu = d.cpuUsage; - if (typeof cpu === "number") { - const newCpu = Math.round(Math.min(100, Math.max(0, cpu + (Math.random() - 0.5) * 8)) * 10) / 10; - currentData[idx].cpuUsage = newCpu; - api.updateData({ accessor: "cpuUsage", rowIndex: idx, newValue: newCpu }); - const hist = d.cpuHistory; - if (Array.isArray(hist) && hist.length > 0) { - const updated = [...hist.slice(1), newCpu]; - currentData[idx].cpuHistory = updated; - api.updateData({ accessor: "cpuHistory", rowIndex: idx, newValue: updated }); - } - } - if (Math.random() < 0.4) { - const mem = d.memoryUsage; - if (typeof mem === "number") { - const n = Math.round(Math.min(100, Math.max(0, mem + (Math.random() - 0.5) * 5)) * 10) / 10; - currentData[idx].memoryUsage = n; - api.updateData({ accessor: "memoryUsage", rowIndex: idx, newValue: n }); - } - } - if (Math.random() < 0.5) { - const rt = d.responseTime; - if (typeof rt === "number") { - const n = Math.round(Math.max(10, rt + (Math.random() - 0.5) * 100) * 10) / 10; - currentData[idx].responseTime = n; - api.updateData({ accessor: "responseTime", rowIndex: idx, newValue: n }); - } - } - scheduleUpdate(); - }, interval); - timerMap.set(rowId, timerId); - }; - scheduleUpdate(); - }; - - const syncTimers = () => { - const api = table.getAPI(); - const visibleRows = api.getVisibleRows(); - const visibleIds = new Set(visibleRows.map((vr) => String(vr.row.id))); - timerMap.forEach((tid, rid) => { - if (!visibleIds.has(rid)) { - clearTimeout(tid); - timerMap.delete(rid); - } - }); - visibleRows.forEach((vr) => { - const rid = String(vr.row.id); - if (!timerMap.has(rid)) createRowTimer(rid); - }); - }; - - const syncInt = setInterval(syncTimers, 500); - const originalDestroy = table.destroy.bind(table); table.destroy = () => { - isActive = false; - clearInterval(syncInt); - timerMap.forEach((t) => clearTimeout(t)); - timerMap.clear(); + stopLiveUpdates?.(); originalDestroy(); }; const originalMount = table.mount.bind(table); table.mount = () => { originalMount(); - syncTimers(); + stopLiveUpdates = startInfraDemoLiveUpdates(() => table.getAPI(), data); }; return table; diff --git a/packages/examples/vanilla/src/demos/infrastructure/infrastructure.demo-data.ts b/packages/examples/vanilla/src/demos/infrastructure/infrastructure.demo-data.ts index af0fad420..a0a744a83 100644 --- a/packages/examples/vanilla/src/demos/infrastructure/infrastructure.demo-data.ts +++ b/packages/examples/vanilla/src/demos/infrastructure/infrastructure.demo-data.ts @@ -187,11 +187,6 @@ export const infrastructureHeaders: HeaderObject[] = [ }, ]; -export const INFRA_UPDATE_CONFIG = { - minInterval: 300, - maxInterval: 1000, -}; - export function getInfraMetricColorStyles( value: number, theme: string, diff --git a/packages/examples/vue/src/demos/infrastructure/InfrastructureDemo.vue b/packages/examples/vue/src/demos/infrastructure/InfrastructureDemo.vue index 8866c52c8..e4bf218b2 100644 --- a/packages/examples/vue/src/demos/infrastructure/InfrastructureDemo.vue +++ b/packages/examples/vue/src/demos/infrastructure/InfrastructureDemo.vue @@ -16,17 +16,127 @@ diff --git a/packages/examples/vue/src/demos/infrastructure/infrastructure.demo-data.ts b/packages/examples/vue/src/demos/infrastructure/infrastructure.demo-data.ts index fcd97f09e..193aa4fa0 100644 --- a/packages/examples/vue/src/demos/infrastructure/infrastructure.demo-data.ts +++ b/packages/examples/vue/src/demos/infrastructure/infrastructure.demo-data.ts @@ -187,11 +187,6 @@ export const infrastructureHeaders: HeaderObject[] = [ }, ]; -export const INFRA_UPDATE_CONFIG = { - minInterval: 300, - maxInterval: 1000, -}; - export function getInfraMetricColorStyles( value: number, theme: string, diff --git a/packages/react/package.json b/packages/react/package.json index d0921d288..8d0395ce1 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@simple-table/react", - "version": "3.0.0", + "version": "3.0.1", "main": "dist/cjs/index.js", "module": "dist/index.es.js", "types": "dist/types/react/src/index.d.ts", diff --git a/packages/react/src/SimpleTable.tsx b/packages/react/src/SimpleTable.tsx index 9cf93b3df..c0ddd18af 100644 --- a/packages/react/src/SimpleTable.tsx +++ b/packages/react/src/SimpleTable.tsx @@ -4,6 +4,21 @@ import type { SimpleTableConfig, TableAPI } from "simple-table-core"; import { buildVanillaConfig } from "./buildVanillaConfig"; import type { SimpleTableReactProps, TableInstance } from "./types"; +/** Top-level referential equality; avoids pushing duplicate `update` when parent re-renders with a new props object. */ +function shallowTablePropsChanged( + prev: SimpleTableReactProps, + next: SimpleTableReactProps, +): boolean { + const keys = new Set([ + ...Object.keys(prev as object), + ...Object.keys(next as object), + ]) as Set; + for (const key of keys) { + if (prev[key] !== next[key]) return true; + } + return false; +} + /** * SimpleTable — React adapter for simple-table-core. * @@ -38,6 +53,7 @@ const SimpleTable = React.forwardRef( const syncedDefaultHeadersRef = useRef( undefined, ); + const lastSyncedPropsRef = useRef(null); // forwardRef omits `ref` from props at the type level; cast it back so // buildVanillaConfig receives the complete SimpleTableReactProps shape. @@ -78,6 +94,7 @@ const SimpleTable = React.forwardRef( instance.destroy(); instanceRef.current = null; syncedDefaultHeadersRef.current = undefined; + lastSyncedPropsRef.current = null; if (ref && typeof ref !== "function") { ref.current = null; } @@ -86,13 +103,21 @@ const SimpleTable = React.forwardRef( // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // Sync prop changes to the vanilla instance after every render (deferred like mount). + // Sync prop changes to the vanilla instance (deferred like mount). // When `defaultHeaders` keeps the same reference, omit it so core does not // reset internal header state (widths, reorder results). New reference = new columns. useLayoutEffect(() => { const instance = instanceRef.current; if (!instance) return; + if ( + lastSyncedPropsRef.current !== null && + !shallowTablePropsChanged(lastSyncedPropsRef.current, reactProps) + ) { + return; + } + lastSyncedPropsRef.current = reactProps; + const fullConfig = buildVanillaConfig(reactProps); queueMicrotask(() => { @@ -107,7 +132,7 @@ const SimpleTable = React.forwardRef( const { defaultHeaders: _headers, ...rest } = fullConfig; instance.update(rest as Partial); }); - }); + }, [reactProps]); return
; }, diff --git a/packages/react/src/utils/wrapReactRenderer.tsx b/packages/react/src/utils/wrapReactRenderer.tsx index b9a0f6e42..49096ca63 100644 --- a/packages/react/src/utils/wrapReactRenderer.tsx +++ b/packages/react/src/utils/wrapReactRenderer.tsx @@ -8,6 +8,24 @@ import type { } from "simple-table-core"; import { domSlotToReactNode, mapColumnEditorRowComponentsForReact } from "./ImperativeDomSlot"; +/** + * Nested `createRoot` renders use `flushSync` so the host has real DOM before + * simple-table-core appends it. `flushSync` must not run while React is already + * in a lifecycle commit (e.g. `useEffect` / `flushPassiveEffects`). Scheduling + * one microtask runs the commit after the caller stack unwinds, which clears + * that warning while still completing before paint in normal browser scheduling. + */ +function scheduleNestedRootCommit( + root: ReturnType, + element: React.ReactElement, +): void { + queueMicrotask(() => { + flushSync(() => { + root.render(element); + }); + }); +} + /** * After assigning innerHTML from renderToStaticMarkup, drop the temporary host * when markup produced exactly one element root (no extra wrapper in the tree). @@ -38,9 +56,7 @@ export function wrapReactRenderer

( return (props: P): HTMLElement => { const container = document.createElement("div"); const root = createRoot(container); - flushSync(() => { - root.render(); - }); + scheduleNestedRootCommit(root, ); return container; }; } @@ -59,9 +75,7 @@ export function wrapReactColumnEditorRowRenderer( ...props, components: mapColumnEditorRowComponentsForReact(props.components), }; - flushSync(() => { - root.render(); - }); + scheduleNestedRootCommit(root, ); return container; }; } @@ -82,9 +96,7 @@ export function wrapReactColumnEditorCustomRenderer( listSection: domSlotToReactNode(props.listSection), resetSection: props.resetSection ? domSlotToReactNode(props.resetSection) : null, }; - flushSync(() => { - root.render(); - }); + scheduleNestedRootCommit(root, ); return container; }; } @@ -92,9 +104,7 @@ export function wrapReactColumnEditorCustomRenderer( /** * Like wrapReactRenderer but uses `display: contents` so layout is unchanged * when core appends this node (no extra box vs a plain div). - * flushSync ensures the tree is committed before the host is returned — required - * when vanilla renders cells from a React effect, where a deferred nested root - * would otherwise yield an empty container. + * Commit scheduling matches {@link wrapReactRenderer} (microtask + flushSync). */ export function wrapReactRendererIntoFragment

( Component: React.ComponentType

, @@ -103,9 +113,7 @@ export function wrapReactRendererIntoFragment

( const container = document.createElement("div"); container.style.display = "contents"; const root = createRoot(container); - flushSync(() => { - root.render(); - }); + scheduleNestedRootCommit(root, ); return container; }; } @@ -124,8 +132,8 @@ export function wrapReactNode(node: React.ReactNode): HTMLElement { * Converts a ReactNode to an HTML string using server-side static rendering. * Used for icon props where the vanilla table expects a string | HTMLElement | SVGSVGElement. * Uses renderToStaticMarkup so it works synchronously from any context — including - * inside a useEffect — unlike createRoot + flushSync which silently produces empty - * output when called during React 18's passive effects phase. + * inside a useEffect — unlike createRoot alone without a follow-up commit, which + * can yield empty output when React defers the nested root. */ export function reactNodeToHtmlString(node: React.ReactNode): string { return renderToStaticMarkup(<>{node}); diff --git a/packages/solid/package.json b/packages/solid/package.json index 035873dbc..ba5dce756 100644 --- a/packages/solid/package.json +++ b/packages/solid/package.json @@ -1,6 +1,6 @@ { "name": "@simple-table/solid", - "version": "3.0.0", + "version": "3.0.1", "main": "dist/cjs/index.js", "module": "dist/index.es.js", "types": "dist/types/solid/src/index.d.ts", diff --git a/packages/svelte/package.json b/packages/svelte/package.json index c22949db1..cf4a510f1 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -1,6 +1,6 @@ { "name": "@simple-table/svelte", - "version": "3.0.0", + "version": "3.0.1", "main": "dist/cjs/index.js", "module": "dist/index.es.js", "types": "dist/types/index.d.ts", diff --git a/packages/vue/package.json b/packages/vue/package.json index cc903b195..7fd18f35e 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -1,6 +1,6 @@ { "name": "@simple-table/vue", - "version": "3.0.0", + "version": "3.0.1", "main": "dist/cjs/index.js", "module": "dist/index.es.js", "types": "dist/types/vue/src/index.d.ts",