From 8a73bd4fbeff25f46cc4591b8c9affc007eb7a50 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Wed, 12 Aug 2026 03:55:08 +0300 Subject: [PATCH] refactor(frontend): give table consumers class hooks that outlive antd Step 4 of the table port. App code styling a table reached into antd's DOM (`[&_.ant-table-thead_tr:nth-child(2)]:hidden` and friends), so replacing the render leaf would silently stop that styling from applying, with no type error and no failing test to catch it. The package now stamps its own hooks and app code targets those instead: avt-table, avt-container, avt-body, avt-thead, avt-row, avt-cell, avt-head-cell. Structural nodes are stamped once on mount. Rows and cells cannot be, because virtualization recycles them, so they get their class through rowClassName and through the column adapter, which is already the one place columns cross into antd. tableDom.ts is now the only file in the directory that spells an antd table selector. The hooks that read the table's DOM go through its map, so the leaf swap edits one file rather than six. Consumers migrated: the observability traces and sessions tables, the agents and prompts sections, and useCellVisibility. globals.css keeps its antd selector alongside the new one, since raw call sites still rely on it. Left for step 3, deliberately: the wrapper-scoped blocks in globals.css, evaluations.css and human-evals.css (.comparison-table, .agenta-testsets-table, .org-domains-table and the rest). They are structural, they carry real visual risk, and most of them belong to raw antd tables that keep .ant-table-* anyway. The contract is pinned by unit tests rather than left to a browser pass, since a broken hook shows up as styling that quietly stops applying. --- .../EvalRunDetails/hooks/useCellVisibility.ts | 3 + .../pages/agents/AgentsTableSection.tsx | 2 +- .../components/ObservabilityTable/index.tsx | 2 +- .../components/SessionsTable/index.tsx | 2 +- .../components/PromptsTableSection.tsx | 2 +- web/oss/src/styles/globals.css | 4 +- .../tests/unit/tableClassHooks.test.ts | 80 +++++++++++++++++++ .../src/InfiniteVirtualTable/antdColumns.ts | 28 ++++++- .../components/InfiniteVirtualTableInner.tsx | 29 +++++-- .../hooks/useColumnDomRefs.ts | 5 +- .../hooks/useScrollConfig.ts | 3 +- .../hooks/useScrollContainer.ts | 6 +- .../hooks/useTableHeaderHeight.ts | 3 +- .../hooks/useTableManager.tsx | 3 +- .../src/InfiniteVirtualTable/index.ts | 1 + .../src/InfiniteVirtualTable/tableDom.ts | 55 +++++++++++++ 16 files changed, 206 insertions(+), 22 deletions(-) create mode 100644 web/packages/agenta-entity-ui/tests/unit/tableClassHooks.test.ts create mode 100644 web/packages/agenta-ui/src/InfiniteVirtualTable/tableDom.ts diff --git a/web/oss/src/components/EvalRunDetails/hooks/useCellVisibility.ts b/web/oss/src/components/EvalRunDetails/hooks/useCellVisibility.ts index cbdfa3cc5d..fd489b5972 100644 --- a/web/oss/src/components/EvalRunDetails/hooks/useCellVisibility.ts +++ b/web/oss/src/components/EvalRunDetails/hooks/useCellVisibility.ts @@ -33,8 +33,11 @@ export const useCellVisibility = () => { return undefined } + // `.avt-body` is the table package's stable hook. The antd selectors stay as a fallback + // for the raw
call sites this hook is also used from. const root = scrollContainer ?? + element.closest(".avt-body") ?? element.closest(".ant-table-body") ?? element.closest(".ant-table-body-inner") ?? null diff --git a/web/oss/src/components/pages/agents/AgentsTableSection.tsx b/web/oss/src/components/pages/agents/AgentsTableSection.tsx index 75beecc12e..deaa678d76 100644 --- a/web/oss/src/components/pages/agents/AgentsTableSection.tsx +++ b/web/oss/src/components/pages/agents/AgentsTableSection.tsx @@ -82,7 +82,7 @@ export default function AgentsTableSection({ return ( - className="grow min-h-0 [&_.ant-table-cell]:!align-middle [&_.ant-table-container]:!border-b" + className="grow min-h-0 [&_.avt-cell]:!align-middle [&_.avt-container]:!border-b" tableScope={tableScope} columns={columns} rowKey={(record) => record.key} diff --git a/web/oss/src/components/pages/observability/components/ObservabilityTable/index.tsx b/web/oss/src/components/pages/observability/components/ObservabilityTable/index.tsx index 047672f754..1c4f2039f8 100644 --- a/web/oss/src/components/pages/observability/components/ObservabilityTable/index.tsx +++ b/web/oss/src/components/pages/observability/components/ObservabilityTable/index.tsx @@ -297,7 +297,7 @@ const ObservabilityTable = () => { enableExport={false} useSettingsDropdown={false} store={store} - className="flex-1 min-h-0 [&_.ant-table-thead_tr:nth-child(2)]:hidden" + className="flex-1 min-h-0 [&_.avt-thead_tr:nth-child(2)]:hidden" rowSelection={{ selectedRowKeys, type: "checkbox", diff --git a/web/oss/src/components/pages/observability/components/SessionsTable/index.tsx b/web/oss/src/components/pages/observability/components/SessionsTable/index.tsx index d9243fd38b..6122d39164 100644 --- a/web/oss/src/components/pages/observability/components/SessionsTable/index.tsx +++ b/web/oss/src/components/pages/observability/components/SessionsTable/index.tsx @@ -138,7 +138,7 @@ const SessionsTable: React.FC = () => { resizableColumns enableExport={false} useSettingsDropdown={false} - className="flex-1 min-h-0 [&_.ant-table-tbody_.ant-table-cell]:align-top" + className="flex-1 min-h-0 [&_.avt-row_.avt-cell]:align-top" tableProps={{ bordered: true, loading: isLoading && sessionIds.length === 0, diff --git a/web/oss/src/components/pages/prompts/components/PromptsTableSection.tsx b/web/oss/src/components/pages/prompts/components/PromptsTableSection.tsx index 74f501615a..a0a73011ee 100644 --- a/web/oss/src/components/pages/prompts/components/PromptsTableSection.tsx +++ b/web/oss/src/components/pages/prompts/components/PromptsTableSection.tsx @@ -190,7 +190,7 @@ export const PromptsTableSection = ({ return ( - className="grow min-h-0 [&_.ant-table-cell]:!align-middle [&_.ant-table-container]:!border-b" + className="grow min-h-0 [&_.avt-cell]:!align-middle [&_.avt-container]:!border-b" tableScope={tableScope} columns={columns} rowKey={(record) => record.key} diff --git a/web/oss/src/styles/globals.css b/web/oss/src/styles/globals.css index 0d243c498b..103d710618 100644 --- a/web/oss/src/styles/globals.css +++ b/web/oss/src/styles/globals.css @@ -74,7 +74,9 @@ body { transition: opacity 0.3s ease; } -.ant-table-row:hover .hover-button-wrapper { +/* .avt-row is the table package's stable hook; the antd selector stays for raw
users. */ +.ant-table-row:hover .hover-button-wrapper, +.avt-row:hover .hover-button-wrapper { opacity: 1; } diff --git a/web/packages/agenta-entity-ui/tests/unit/tableClassHooks.test.ts b/web/packages/agenta-entity-ui/tests/unit/tableClassHooks.test.ts new file mode 100644 index 0000000000..1e41e2c0ca --- /dev/null +++ b/web/packages/agenta-entity-ui/tests/unit/tableClassHooks.test.ts @@ -0,0 +1,80 @@ +import {AVT, stampTableDom, toAntdColumns} from "@agenta/ui/table" +import {describe, expect, it} from "vitest" + +/** + * The table's stable class hooks. App code targets `avt-*` so a selector does not depend on + * antd's DOM, which the render-leaf swap will replace. If these break, consumer styling + * silently stops applying, so the contract is pinned here rather than left to a browser pass. + */ + +interface FakeNode { + classes: Set + classList: {add: (c: string) => void} +} + +const node = (): FakeNode => { + const classes = new Set() + return {classes, classList: {add: (c: string) => classes.add(c)}} +} + +/** Minimal stand-in for the mounted table: querySelector over a fixed selector map. */ +const container = (found: Record) => + ({ + querySelector: (selector: string) => found[selector] ?? null, + }) as unknown as HTMLElement + +describe("stampTableDom", () => { + it("stamps the structural hooks onto antd's nodes", () => { + const nodes = { + ".ant-table-container": node(), + ".ant-table-body": node(), + ".ant-table-thead": node(), + } + stampTableDom(container(nodes)) + + expect([...nodes[".ant-table-container"].classes]).toEqual([AVT.container]) + expect([...nodes[".ant-table-body"].classes]).toEqual([AVT.body]) + expect([...nodes[".ant-table-thead"].classes]).toEqual([AVT.header]) + }) + + it("skips nodes that are not present rather than throwing", () => { + expect(() => stampTableDom(container({}))).not.toThrow() + expect(() => stampTableDom(null)).not.toThrow() + }) +}) + +describe("toAntdColumns cell hooks", () => { + interface Row { + id: string + } + + it("adds the cell hooks to a plain column", () => { + const [column] = toAntdColumns([{key: "id", title: "ID"}]) + + expect(column.onCell?.({id: "a"}, 0)).toEqual({className: AVT.cell}) + expect(column.onHeaderCell?.(column, 0)).toEqual({className: AVT.headerCell}) + }) + + it("keeps a column's own cell props and appends the hook", () => { + const [column] = toAntdColumns([ + { + key: "id", + onCell: () => ({className: "mine", colSpan: 2}), + }, + ]) + + expect(column.onCell?.({id: "a"}, 0)).toEqual({ + className: `mine ${AVT.cell}`, + colSpan: 2, + }) + }) + + it("reaches columns nested in a group", () => { + const [group] = toAntdColumns([ + {key: "g", title: "Group", children: [{key: "id", title: "ID"}]}, + ]) + const child = (group as {children: (typeof group)[]}).children[0] + + expect(child.onCell?.({id: "a"}, 0)).toEqual({className: AVT.cell}) + }) +}) diff --git a/web/packages/agenta-ui/src/InfiniteVirtualTable/antdColumns.ts b/web/packages/agenta-ui/src/InfiniteVirtualTable/antdColumns.ts index 2351e17fcc..b4af47609d 100644 --- a/web/packages/agenta-ui/src/InfiniteVirtualTable/antdColumns.ts +++ b/web/packages/agenta-ui/src/InfiniteVirtualTable/antdColumns.ts @@ -1,6 +1,8 @@ import type {ColumnsType as AntdColumnsType} from "antd/es/table" -import type {ColumnDefs} from "./columnDef" +import type {ColumnCellProps, ColumnDefs} from "./columnDef" +import {isColumnGroupDef} from "./columnDef" +import {AVT} from "./tableDom" /** * The one place the table's own column model meets antd's. @@ -11,8 +13,30 @@ import type {ColumnDefs} from "./columnDef" * rather than inferred. Keep the assertion here; do not import antd column types elsewhere in * this directory. */ + +const withClass = (props: ColumnCellProps | undefined, className: string): ColumnCellProps => ({ + ...props, + className: props?.className ? `${props.className} ${className}` : className, +}) + +/** + * Stamps the stable cell hooks. Cells are recycled by virtualization, so they cannot be + * stamped from a mount effect the way the structural nodes are. + */ +const withCellHooks = (columns: ColumnDefs): ColumnDefs => + columns.map((column) => { + const next = { + ...column, + onCell: (record: RecordType, index?: number) => + withClass(column.onCell?.(record, index), AVT.cell), + onHeaderCell: (col: ColumnDefs[number], index?: number) => + withClass(column.onHeaderCell?.(col, index), AVT.headerCell), + } + return isColumnGroupDef(column) ? {...next, children: withCellHooks(column.children)} : next + }) as ColumnDefs + export const toAntdColumns = (columns: ColumnDefs) => - columns as unknown as AntdColumnsType + withCellHooks(columns) as unknown as AntdColumnsType /** Columns arriving from an antd-typed call site, on their way into the table. */ export const fromAntdColumns = (columns: AntdColumnsType) => diff --git a/web/packages/agenta-ui/src/InfiniteVirtualTable/components/InfiniteVirtualTableInner.tsx b/web/packages/agenta-ui/src/InfiniteVirtualTable/components/InfiniteVirtualTableInner.tsx index c607b24852..3fa7ae06f2 100644 --- a/web/packages/agenta-ui/src/InfiniteVirtualTable/components/InfiniteVirtualTableInner.tsx +++ b/web/packages/agenta-ui/src/InfiniteVirtualTable/components/InfiniteVirtualTableInner.tsx @@ -40,6 +40,7 @@ import useTableRowSelection from "../hooks/useTableRowSelection" import {useTypeChipColumns} from "../hooks/useTypeChipColumns" import {useTypeChipFeature} from "../hooks/useTypeChipFeature" import ColumnVisibilityProvider from "../providers/ColumnVisibilityProvider" +import {ANTD_SELECTOR, AVT, stampTableDom} from "../tableDom" import type {InfiniteVirtualTableProps} from "../types" import { buildColumnDescendantMap, @@ -190,9 +191,7 @@ const InfiniteVirtualTableInnerBase = ({ return } const headerCells = Array.from( - container.querySelectorAll( - ".ant-table-thead th[data-column-key]", - ), + container.querySelectorAll(ANTD_SELECTOR.headerCellWithKey), ).filter((cell) => Number(cell.getAttribute("colspan") ?? "1") === 1) if (!headerCells.length) { columnDomRefs.current = new Map() @@ -329,7 +328,7 @@ const InfiniteVirtualTableInnerBase = ({ const tables = container.querySelectorAll(".ant-table table") tables.forEach((table) => { const selectionCol = table.querySelector( - "colgroup col.ant-table-selection-col", + ANTD_SELECTOR.selectionCol, ) if (selectionCol) { selectionCol.style.width = widthPx @@ -339,7 +338,7 @@ const InfiniteVirtualTableInnerBase = ({ }) const headerCells = container.querySelectorAll( - ".ant-table-thead th.ant-table-selection-column", + ANTD_SELECTOR.headerSelectionCell, ) headerCells.forEach((cell) => { cell.style.width = widthPx @@ -366,7 +365,7 @@ const InfiniteVirtualTableInnerBase = ({ return } const headerEl = - container.querySelector(".ant-table-thead") ?? + container.querySelector(ANTD_SELECTOR.header) ?? container.querySelector("table thead") if (!headerEl) { setTableHeaderHeight(null) @@ -411,7 +410,7 @@ const InfiniteVirtualTableInnerBase = ({ const headerHeight = (typeof tableHeaderHeight === "number" && Number.isFinite(tableHeaderHeight) ? tableHeaderHeight - : (containerRef.current?.querySelector(".ant-table-thead") as HTMLElement | null) + : (containerRef.current?.querySelector(ANTD_SELECTOR.header) as HTMLElement | null) ?.offsetHeight) ?? null const computedY = Math.max((scrollY ?? 0) - (headerHeight ?? 0), 0) @@ -692,6 +691,20 @@ const InfiniteVirtualTableInnerBase = ({ const columnVisibilityVersion = version const tableComponentRef = tableRef as unknown as Ref + // Stable class hooks for app code, so a consumer's selector does not depend on antd's DOM. + // The structural nodes exist for the table's lifetime; rows and cells get theirs from + // rowClassName and the column adapter, because virtualization recycles them. + useEffect(() => { + stampTableDom(containerRef.current) + }, [dataSource]) + + const rowClassName = useMemo["rowClassName"]>(() => { + const inherited = tablePropsWithShortcuts.rowClassName + if (!inherited) return AVT.row + if (typeof inherited !== "function") return cn(inherited, AVT.row) + return (record, index, indent) => cn(inherited(record, index, indent), AVT.row) + }, [tablePropsWithShortcuts.rowClassName]) + useEffect(() => { const key = resolvedScopeId if (!key) return undefined @@ -731,6 +744,7 @@ const InfiniteVirtualTableInnerBase = ({
({ rowSelection={tableRowSelection} expandable={tableExpandable} {...tablePropsWithShortcuts} + rowClassName={rowClassName} scroll={{ x: scrollConfig.x, y: scrollConfig.y, diff --git a/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useColumnDomRefs.ts b/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useColumnDomRefs.ts index 45de13d2df..aa02352e45 100644 --- a/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useColumnDomRefs.ts +++ b/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useColumnDomRefs.ts @@ -1,6 +1,7 @@ import {useLayoutEffect, useRef} from "react" import type {ColumnDefs} from "../columnDef" +import {ANTD_SELECTOR} from "../tableDom" interface ColumnDomRefs { cols: HTMLTableColElement[] @@ -24,9 +25,7 @@ const useColumnDomRefs = ( } const headerCells = Array.from( - container.querySelectorAll( - ".ant-table-thead th[data-column-key]", - ), + container.querySelectorAll(ANTD_SELECTOR.headerCellWithKey), ).filter((cell) => Number(cell.getAttribute("colspan") ?? "1") === 1) if (!headerCells.length) { diff --git a/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useScrollConfig.ts b/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useScrollConfig.ts index 2bc84e02ad..5fc7606edc 100644 --- a/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useScrollConfig.ts +++ b/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useScrollConfig.ts @@ -2,6 +2,7 @@ import {useMemo, useRef, type RefObject} from "react" import type {TableProps} from "antd/es/table" +import {ANTD_SELECTOR} from "../tableDom" import {shallowEqual} from "../utils/columnUtils" interface UseScrollConfigOptions { @@ -50,7 +51,7 @@ const useScrollConfig = ({ const headerHeight = (typeof tableHeaderHeight === "number" && Number.isFinite(tableHeaderHeight) ? tableHeaderHeight - : (containerRef.current?.querySelector(".ant-table-thead") as HTMLElement | null) + : (containerRef.current?.querySelector(ANTD_SELECTOR.header) as HTMLElement | null) ?.offsetHeight) ?? null const computedY = Math.max((containerHeight ?? 0) - (headerHeight ?? 0), 0) diff --git a/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useScrollContainer.ts b/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useScrollContainer.ts index 0a82f638a0..0ae9ebba66 100644 --- a/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useScrollContainer.ts +++ b/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useScrollContainer.ts @@ -1,5 +1,7 @@ import {useEffect, useRef, useState} from "react" +import {ANTD_SELECTOR} from "../tableDom" + interface ScrollContainerResult { scrollContainer: HTMLDivElement | null visibilityRoot: HTMLDivElement | null @@ -33,7 +35,7 @@ const useScrollContainer = ( return } - const tableBody = containerElement.querySelector(".ant-table-body") ?? null + const tableBody = containerElement.querySelector(ANTD_SELECTOR.body) ?? null const isScrollable = (element: HTMLDivElement | null) => { if (!element) return false @@ -52,7 +54,7 @@ const useScrollContainer = ( } const headerContainer = - containerElement.querySelector(".ant-table-container") ?? + containerElement.querySelector(ANTD_SELECTOR.container) ?? containerElement if (headerContainer !== lastVisibilityRootRef.current) { diff --git a/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useTableHeaderHeight.ts b/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useTableHeaderHeight.ts index 9e16e47254..e03838e771 100644 --- a/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useTableHeaderHeight.ts +++ b/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useTableHeaderHeight.ts @@ -3,6 +3,7 @@ import {useLayoutEffect, useState, type RefObject} from "react" import type {TableProps} from "antd/es/table" import type {ColumnDefs} from "../columnDef" +import {ANTD_SELECTOR} from "../tableDom" interface UseTableHeaderHeightOptions { containerRef: RefObject @@ -29,7 +30,7 @@ const useTableHeaderHeight = ({ return } const headerEl = - container.querySelector(".ant-table-thead") ?? + container.querySelector(ANTD_SELECTOR.header) ?? container.querySelector("table thead") if (!headerEl) { setTableHeaderHeight(null) diff --git a/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useTableManager.tsx b/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useTableManager.tsx index f7525b95ec..4cba009b7d 100644 --- a/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useTableManager.tsx +++ b/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useTableManager.tsx @@ -17,6 +17,7 @@ import type { TableDeleteConfig, TableExportConfig, } from "../features/InfiniteVirtualTableFeatureShell" +import {ANTD_SELECTOR} from "../tableDom" import type { InfiniteTableRowBase, InfiniteVirtualTableProps, @@ -31,7 +32,7 @@ const dummySearchAtom = atom("") const INTERACTIVE_SELECTOR = "button, a, input, textarea, select, [role='button'], [role='menuitem'], [role='checkbox'], " + ".ant-btn, .ant-checkbox, .ant-checkbox-input, .ant-checkbox-inner, .ant-checkbox-wrapper, " + - ".ant-select, .ant-dropdown-trigger, .ant-table-selection-column, .ag-table-actions-cell" + ANTD_SELECTOR.interactiveCell /** * Returns true when the click originated from an interactive element (button, link, diff --git a/web/packages/agenta-ui/src/InfiniteVirtualTable/index.ts b/web/packages/agenta-ui/src/InfiniteVirtualTable/index.ts index 7cdc4a2345..5b245f0148 100644 --- a/web/packages/agenta-ui/src/InfiniteVirtualTable/index.ts +++ b/web/packages/agenta-ui/src/InfiniteVirtualTable/index.ts @@ -137,6 +137,7 @@ export type { ColumnSorterConfig, } from "./columnDef" export {toAntdColumns, fromAntdColumns} from "./antdColumns" +export {AVT, ANTD_SELECTOR, stampTableDom, type AvtClass} from "./tableDom" export type {VisibilityRegistrationHandler} from "./components/ColumnVisibilityHeader" // Shared hooks for cell renderers diff --git a/web/packages/agenta-ui/src/InfiniteVirtualTable/tableDom.ts b/web/packages/agenta-ui/src/InfiniteVirtualTable/tableDom.ts new file mode 100644 index 0000000000..ff2c2a4a83 --- /dev/null +++ b/web/packages/agenta-ui/src/InfiniteVirtualTable/tableDom.ts @@ -0,0 +1,55 @@ +/** + * The table's DOM contract, in one file. + * + * `AVT` names are stamped onto the rendered table by the package and are the only hooks app + * code should target. They survive the render-leaf swap; `.ant-table-*` will not. + * + * `ANTD_SELECTOR` is where each hook lives in antd's DOM today. It is the implementation + * detail the swap replaces, so keep every `.ant-table-*` string in this file and nowhere else + * in the directory. + */ + +export const AVT = { + root: "avt-table", + container: "avt-container", + body: "avt-body", + header: "avt-thead", + row: "avt-row", + cell: "avt-cell", + headerCell: "avt-head-cell", +} as const + +export type AvtClass = (typeof AVT)[keyof typeof AVT] + +export const ANTD_SELECTOR = { + container: ".ant-table-container", + body: ".ant-table-body", + bodyInner: ".ant-table-body-inner", + header: ".ant-table-thead", + headerCellWithKey: ".ant-table-thead th[data-column-key]", + headerSelectionCell: ".ant-table-thead th.ant-table-selection-column", + selectionCol: "colgroup col.ant-table-selection-col", + /** Cells that own their click, so row-click shortcuts skip them. */ + interactiveCell: + ".ant-select, .ant-dropdown-trigger, .ant-table-selection-column, .ag-table-actions-cell", +} as const + +/** Structural nodes rendered once per table, so a mount-time stamp is enough. */ +const STAMPED: [selector: string, className: string][] = [ + [ANTD_SELECTOR.container, AVT.container], + [ANTD_SELECTOR.body, AVT.body], + [ANTD_SELECTOR.header, AVT.header], +] + +/** + * Adds the stable class hooks to a mounted table. + * + * Rows and cells are not stamped here — virtualization recycles them, so they get their class + * through `rowClassName` and the column adapter instead. + */ +export const stampTableDom = (container: HTMLElement | null): void => { + if (!container) return + for (const [selector, className] of STAMPED) { + container.querySelector(selector)?.classList.add(className) + } +}