From 66f3d48339cfec9660783c311d67308c3f731206 Mon Sep 17 00:00:00 2001 From: Guillaume LADORME Date: Thu, 22 Oct 2026 16:06:30 +0200 Subject: [PATCH 1/2] [ENHANCEMENT] Compile TanStack Table v9 components with the React Compiler Remove the `'use no memo'` opt-outs from Table, VirtualizedTable and TableToolbar now that TanStack Table v9 supports the React Compiler: - VirtualizedTable receives the `useTable` value, whose identity changes with table state, and reads rows and headers from it inside its render callbacks so compiled memoization invalidates on selection, sorting and sizing changes. - TableToolbar subscribes to the column visibility atom for its menu checkboxes, since columns keep their identity when visibility changes. - Row clicks pass the row and use its stable core table, so state changes no longer recreate the Virtuoso components and remount every row. - Call `column.resetSize()` on its instance; v9 instance methods are prototype methods and fail when detached. Vitest now compiles sources with the same SWC React Compiler settings as the build, and stateful Table tests cover the regressions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Guillaume LADORME --- README.md | 3 + components/src/Table/Table.test.tsx | 134 +++++++++++++++++++++- components/src/Table/Table.tsx | 18 +-- components/src/Table/TableToolbar.tsx | 22 ++-- components/src/Table/VirtualizedTable.tsx | 44 +++---- vitest.shared.ts | 44 ++++++- 6 files changed, 221 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index da4aaaa8..aabb836a 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,9 @@ npm run build npm run test ``` +Tests compile package sources with the same SWC React Compiler settings as the build (see `vitest.shared.ts`), so they +exercise the memoized output that is published. + #### Type checking ```bash diff --git a/components/src/Table/Table.test.tsx b/components/src/Table/Table.test.tsx index 2db99df9..3f4f1b5c 100644 --- a/components/src/Table/Table.test.tsx +++ b/components/src/Table/Table.test.tsx @@ -12,8 +12,10 @@ // limitations under the License. import type { RenderResult } from '@testing-library/react'; -import { render, screen, getAllByRole, within } from '@testing-library/react'; +import { fireEvent, render, screen, getAllByRole, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import type { ReactElement } from 'react'; +import { useState } from 'react'; import { VirtuosoMockContext } from 'react-virtuoso'; import type { TableCellConfigs, TableColumnConfig, TableProps } from './model/table-model'; @@ -124,6 +126,58 @@ const LARGE_TABLE_OVERALL_ROWS = 100; const LARGE_TABLE_VISIBLE_ROWS = 5; const LARGE_TABLE_DATA = generateMockTableData(LARGE_TABLE_OVERALL_ROWS); +const STATEFUL_TABLE_DATA = generateMockTableData(3); +const STATEFUL_TABLE_VIRTUOSO_CONTEXT = { viewportHeight: 600, itemHeight: MOCK_ITEM_HEIGHT }; + +/** + * Feeds table state changes back into the table like a real consumer does, so + * tests cover re-renders driven by state rather than only the emitted events. + */ +function StatefulTable(props: Partial>): ReactElement { + const [rowSelection, setRowSelection] = useState['rowSelection']>>({}); + const [sorting, setSorting] = useState['sorting']>>([]); + + return ( + + + + ); +} + +const RESIZABLE_COLUMN_CONFIG = { enableResizing: true }; +const COLUMN_FILTER_TOOLBAR_CONFIG = { isColumnFilterEnabled: true }; + +function getColumnHeader(index: number): HTMLElement { + const columnHeader = screen.getAllByRole('columnheader')[index]; + if (!columnHeader) { + throw new Error(`Cannot find column header at index: ${index}.`); + } + return columnHeader; +} + +function getHeaderCheckbox(): HTMLElement { + return within(getColumnHeader(0)).getByRole('checkbox'); +} + +function getSortButton(): HTMLElement { + return within(getColumnHeader(2)).getByRole('button'); +} + +function getSortIcon(): HTMLElement { + return within(getColumnHeader(2)).getByTestId('ArrowDownwardIcon'); +} + const renderLargeTable = (): void => { const height = LARGE_TABLE_VISIBLE_ROWS * MOCK_ITEM_HEIGHT; @@ -1182,4 +1236,82 @@ describe('table', () => { expect(cell4.firstChild).not.toHaveStyle({ 'background-color': 'rgb(255, 0, 0)' }); expect(cell4.firstChild).not.toHaveStyle({ color: 'green' }); }); + + describe('when the parent feeds table state back in', () => { + test('header checkbox toggles between all and none on repeated clicks', () => { + render(); + + for (let i = 0; i < 2; i++) { + userEvent.click(getHeaderCheckbox()); + expect(getHeaderCheckbox()).toBeChecked(); + screen.getAllByRole('checkbox').forEach((checkbox) => expect(checkbox).toBeChecked()); + + userEvent.click(getHeaderCheckbox()); + expect(getHeaderCheckbox()).not.toBeChecked(); + screen.getAllByRole('checkbox').forEach((checkbox) => expect(checkbox).not.toBeChecked()); + } + }); + + test('keeps rendered rows mounted when the selection changes', () => { + render(); + + const rowBefore = screen.getAllByRole('row')[2]; + userEvent.click(getTableCellByIndex(1, 1)); + + expect(screen.getAllByRole('row')[2]).toBe(rowBefore); + }); + + test('sort indicator follows repeated header clicks', () => { + render(); + + userEvent.click(getSortButton()); + expect(getSortButton()).toHaveClass('Mui-active'); + expect(getSortIcon()).toHaveClass('MuiTableSortLabel-iconDirectionDesc'); + + userEvent.click(getSortButton()); + expect(getSortButton()).toHaveClass('Mui-active'); + expect(getSortIcon()).toHaveClass('MuiTableSortLabel-iconDirectionAsc'); + + userEvent.click(getSortButton()); + expect(getSortButton()).not.toHaveClass('Mui-active'); + }); + + test('column visibility menu reflects toggled columns', () => { + render(); + userEvent.click(screen.getByRole('button', { expanded: false })); + const menuItem = within(screen.getByRole('menu')).getByText('Value').closest('li'); + if (!menuItem) { + throw new Error('Cannot find column visibility menu item'); + } + + userEvent.click(menuItem); + expect(within(menuItem).getByRole('checkbox')).not.toBeChecked(); + expect(within(screen.getByRole('table', { hidden: true })).queryByText('Value')).not.toBeInTheDocument(); + + userEvent.click(menuItem); + expect(within(menuItem).getByRole('checkbox')).toBeChecked(); + expect(within(screen.getByRole('table', { hidden: true })).getByText('Value')).toBeInTheDocument(); + }); + + test('resizing a column updates its size and double clicking the handle resets it', () => { + const { container } = render(); + const getColumnSize = (): string => + (container.querySelector('[style*="--col-value-size"]') as HTMLElement).style.getPropertyValue( + '--col-value-size', + ); + const resizeHandle = within(getColumnHeader(2)).getByRole('separator').parentElement; + if (!resizeHandle) { + throw new Error('Cannot find resize handle'); + } + const initialSize = getColumnSize(); + + fireEvent.mouseDown(resizeHandle, { clientX: 100 }); + fireEvent.mouseMove(document, { clientX: 150 }); + fireEvent.mouseUp(document, { clientX: 150 }); + expect(getColumnSize()).toBe(String(Number(initialSize) + 50)); + + fireEvent.mouseDown(resizeHandle, { detail: 2 }); + expect(getColumnSize()).toBe(initialSize); + }); + }); }); diff --git a/components/src/Table/Table.tsx b/components/src/Table/Table.tsx index 5520dfeb..05c96523 100644 --- a/components/src/Table/Table.tsx +++ b/components/src/Table/Table.tsx @@ -76,8 +76,6 @@ export function Table({ defaultColumnConfig, ...otherProps }: TableProps): ReactElement { - 'use no memo'; // TanStack Table v8 uses a mutable instance that automatic memoization cannot track. - const theme = useTheme(); const hasSubRows = !!getSubRows; @@ -236,13 +234,14 @@ export function Table({ }, }); + // Use the row's stable core table rather than `table`, whose identity changes + // with every state update and would otherwise remount all virtualized rows. const handleRowClick = useCallback( - (e: React.MouseEvent, rowId: string) => { - const row = table.getRow(rowId); + (e: React.MouseEvent, row: Row) => { const isModifiedClick = e.metaKey || e.shiftKey; - handleRowSelectionEvent(table, row, isModifiedClick); + handleRowSelectionEvent(row.table, row, isModifiedClick); }, - [handleRowSelectionEvent, table], + [handleRowSelectionEvent], ); return ( @@ -252,15 +251,10 @@ export function Table({ defaultColumnWidth={defaultColumnWidth} defaultColumnHeight={defaultColumnHeight} onRowClick={handleRowClick} - rows={table.getRowModel().rows} - columns={table.getVisibleFlatColumns()} - columnSizing={table.state.columnSizing} - columnResizing={table.state.columnResizing} - headers={table.getHeaderGroups()} + table={table} cellConfigs={cellConfigs} pagination={pagination} onPaginationChange={onPaginationChange} - rowCount={table.getRowCount()} toolbarConfig={{ isSearchEnabled: tableToolbarConfig?.isSearchEnabled, globalFilter, diff --git a/components/src/Table/TableToolbar.tsx b/components/src/Table/TableToolbar.tsx index a7d2f5dd..6672c88e 100644 --- a/components/src/Table/TableToolbar.tsx +++ b/components/src/Table/TableToolbar.tsx @@ -13,6 +13,7 @@ import { Checkbox, IconButton, InputAdornment, ListItemText, Menu, MenuItem, Stack, Tooltip } from '@mui/material'; import type { Column, RowData } from '@tanstack/react-table'; +import { Subscribe } from '@tanstack/react-table'; import Close from 'mdi-material-ui/Close'; import Magnify from 'mdi-material-ui/Magnify'; import UnfoldLess from 'mdi-material-ui/UnfoldLessHorizontal'; @@ -87,8 +88,6 @@ export function TableToolbar({ isAllExpanded, onExpandAllChange, }: TableToolbarProps): ReactElement | null { - 'use no memo'; // TanStack Table columns retain their identity when visibility changes. - const [colMenuAnchor, setColMenuAnchor] = useState(null); const colMenuOpen = Boolean(colMenuAnchor); const [searchResetKey, setSearchResetKey] = useState(0); @@ -177,13 +176,18 @@ export function TableToolbar({ onClick={column.getCanHide() ? column.getToggleVisibilityHandler() : undefined} dense > - + {/* Columns keep their identity when visibility changes, so subscribe to it explicitly. */} + + {() => ( + + )} + ); diff --git a/components/src/Table/VirtualizedTable.tsx b/components/src/Table/VirtualizedTable.tsx index 6017133e..91584dac 100644 --- a/components/src/Table/VirtualizedTable.tsx +++ b/components/src/Table/VirtualizedTable.tsx @@ -12,7 +12,7 @@ // limitations under the License. import { Box, TablePagination, TableRow as MuiTableRow } from '@mui/material'; -import type { Column, columnResizingState, ColumnSizingState, HeaderGroup, Row, RowData } from '@tanstack/react-table'; +import type { ReactTable, Row, RowData } from '@tanstack/react-table'; import { flexRender } from '@tanstack/react-table'; import type { ReactElement } from 'react'; import { useMemo, useRef } from 'react'; @@ -42,14 +42,14 @@ export type VirtualizedTableProps = Required< Pick, 'height' | 'width' | 'density' | 'defaultColumnHeight' | 'defaultColumnWidth'> > & Pick, 'onRowMouseOver' | 'onRowMouseOut' | 'pagination' | 'onPaginationChange'> & { - onRowClick: (e: React.MouseEvent, id: string) => void; - rows: Array>; - columns: Array>; - headers: Array>; - columnSizing: ColumnSizingState; - columnResizing: columnResizingState; + onRowClick: (e: React.MouseEvent, row: Row) => void; + /** + * The value returned by `useTable`. Its identity changes whenever table + * state or options change, unlike the row, column, and header objects it + * returns, which keep their identities across state changes. + */ + table: ReactTable; cellConfigs?: TableCellConfigs; - rowCount: number; toolbarConfig: Pick< TableToolbarProps, | 'isSearchEnabled' @@ -76,16 +76,18 @@ export function VirtualizedTable({ onRowClick, onRowMouseOver, onRowMouseOut, - rows, - columns, - headers, + table, cellConfigs, pagination, onPaginationChange, - rowCount, toolbarConfig, }: VirtualizedTableProps): ReactElement { - 'use no memo'; // TanStack Table row and column getters read mutable state behind stable identities. + // Rows, columns, and headers keep their identities when table state such as + // selection, sorting, or sizing changes. Anything that reads that state + // through their methods must call `table` directly in the same expression or + // callback, so the React Compiler invalidates it whenever `table` changes. + const rows = table.getRowModel().rows; + const columnCount = table.getVisibleFlatColumns().length; const virtuosoRef = useRef(null); // Use a ref for these values because they are only needed for keyboard @@ -106,7 +108,7 @@ export function VirtualizedTable({ // We add 1 here for the header. maxRows: rows.length + 1, - maxColumns: columns.length, + maxColumns: columnCount, }); const getFocusState = (cellPosition: TableCellPosition): TableCellProps['focusState'] => { @@ -145,7 +147,7 @@ export function VirtualizedTable({ return ( onRowClick(e, row.id)} + onClick={(e) => onRowClick(e, row)} density={density} onMouseOver={(e) => { onRowMouseOver?.(e, rowEventOpts); @@ -186,7 +188,7 @@ export function VirtualizedTable({ * and pass the column sizes down as CSS variables to the
element. */ const columnSizeVars: { [key: string]: number } = {}; - headers.forEach((headerGroup) => { + table.getHeaderGroups().forEach((headerGroup) => { headerGroup.headers .filter((header) => header.column.getCanResize()) .forEach((header) => { @@ -209,7 +211,7 @@ export function VirtualizedTable({ fixedHeaderContent={() => { return ( <> - {headers.map((headerGroup) => { + {table.getHeaderGroups().map((headerGroup) => { return ( {headerGroup.headers.map((header, i, headers) => { @@ -246,7 +248,7 @@ export function VirtualizedTable({ header.column.getCanResize() ? { resizeHandler: header.getResizeHandler(), - resetSizeHandler: header.column.resetSize, + resetSizeHandler: () => header.column.resetSize(), isResizing: header.column.getIsResizing(), } : undefined @@ -267,8 +269,8 @@ export function VirtualizedTable({ ? (): ReactElement => ( theme.palette.background.default }}> ({ : undefined } itemContent={(index) => { - const row = rows[index]; + const row = table.getRowModel().rows[index]; if (!row) { return null; } diff --git a/vitest.shared.ts b/vitest.shared.ts index 23af475d..4029e960 100644 --- a/vitest.shared.ts +++ b/vitest.shared.ts @@ -11,10 +11,14 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import type { JscConfig } from '@swc/core'; +import { transform } from '@swc/core'; import tsconfigPaths from 'vite-tsconfig-paths'; +import type { Plugin } from 'vitest/config'; import { defineConfig, mergeConfig } from 'vitest/config'; type VitestPackageOptions = { @@ -25,8 +29,46 @@ type VitestPackageOptions = { const repoRoot = fileURLToPath(new URL('.', import.meta.url)); +/** + * Compiles source files with the same SWC React Compiler settings as the package build, so tests exercise the + * memoized output that is published instead of the uncompiled source. + */ +function reactCompilerPlugin(): Plugin { + const swcrc = JSON.parse(readFileSync(resolve(repoRoot, '.swcrc'), 'utf8')) as { + jsc: JscConfig; + exclude?: string[]; + }; + const { baseUrl: _baseUrl, ...jsc } = swcrc.jsc; + const excludePatterns = (swcrc.exclude ?? []).map((pattern) => new RegExp(pattern)); + + return { + name: 'perses:react-compiler', + enforce: 'pre', + async transform(code, id) { + const filename = id.split('?')[0] ?? id; + if ( + !/\.tsx?$/.test(filename) || + filename.includes('/node_modules/') || + !filename.startsWith(repoRoot) || + excludePatterns.some((pattern) => pattern.test(filename)) + ) { + return null; + } + + const output = await transform(code, { + filename, + swcrc: false, + configFile: false, + sourceMaps: true, + jsc, + }); + return { code: output.code, map: output.map }; + }, + }; +} + const sharedConfig = defineConfig({ - plugins: [tsconfigPaths({ projects: [resolve(repoRoot, 'tsconfig.vitest.json')] })], + plugins: [reactCompilerPlugin(), tsconfigPaths({ projects: [resolve(repoRoot, 'tsconfig.vitest.json')] })], resolve: { alias: { 'use-resize-observer': 'use-resize-observer/polyfilled', From 1688caf071cfa0ba1fd0c34651d14531c40d691c Mon Sep 17 00:00:00 2001 From: Guillaume LADORME Date: Thu, 22 Oct 2026 16:22:36 +0200 Subject: [PATCH 2/2] [ENHANCEMENT] Keep Vitest config unchanged for Table compiler fixes Revert the React Compiler transform in vitest.shared.ts and its README note. Drop the row remount test, which only holds when the keyboard navigation hook is compiled, and re-query the resize handle so the resize test works against uncompiled sources. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Guillaume LADORME --- README.md | 3 -- components/src/Table/Table.test.tsx | 26 ++++++++--------- vitest.shared.ts | 44 +---------------------------- 3 files changed, 12 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index aabb836a..da4aaaa8 100644 --- a/README.md +++ b/README.md @@ -48,9 +48,6 @@ npm run build npm run test ``` -Tests compile package sources with the same SWC React Compiler settings as the build (see `vitest.shared.ts`), so they -exercise the memoized output that is published. - #### Type checking ```bash diff --git a/components/src/Table/Table.test.tsx b/components/src/Table/Table.test.tsx index 3f4f1b5c..9f2a0b83 100644 --- a/components/src/Table/Table.test.tsx +++ b/components/src/Table/Table.test.tsx @@ -178,6 +178,15 @@ function getSortIcon(): HTMLElement { return within(getColumnHeader(2)).getByTestId('ArrowDownwardIcon'); } +function getResizeHandle(): HTMLElement { + // Query on each use because uncompiled re-renders can remount the header row. + const resizeHandle = within(getColumnHeader(2)).getByRole('separator').parentElement; + if (!resizeHandle) { + throw new Error('Cannot find resize handle'); + } + return resizeHandle; +} + const renderLargeTable = (): void => { const height = LARGE_TABLE_VISIBLE_ROWS * MOCK_ITEM_HEIGHT; @@ -1252,15 +1261,6 @@ describe('table', () => { } }); - test('keeps rendered rows mounted when the selection changes', () => { - render(); - - const rowBefore = screen.getAllByRole('row')[2]; - userEvent.click(getTableCellByIndex(1, 1)); - - expect(screen.getAllByRole('row')[2]).toBe(rowBefore); - }); - test('sort indicator follows repeated header clicks', () => { render(); @@ -1299,18 +1299,14 @@ describe('table', () => { (container.querySelector('[style*="--col-value-size"]') as HTMLElement).style.getPropertyValue( '--col-value-size', ); - const resizeHandle = within(getColumnHeader(2)).getByRole('separator').parentElement; - if (!resizeHandle) { - throw new Error('Cannot find resize handle'); - } const initialSize = getColumnSize(); - fireEvent.mouseDown(resizeHandle, { clientX: 100 }); + fireEvent.mouseDown(getResizeHandle(), { clientX: 100 }); fireEvent.mouseMove(document, { clientX: 150 }); fireEvent.mouseUp(document, { clientX: 150 }); expect(getColumnSize()).toBe(String(Number(initialSize) + 50)); - fireEvent.mouseDown(resizeHandle, { detail: 2 }); + fireEvent.mouseDown(getResizeHandle(), { detail: 2 }); expect(getColumnSize()).toBe(initialSize); }); }); diff --git a/vitest.shared.ts b/vitest.shared.ts index 4029e960..23af475d 100644 --- a/vitest.shared.ts +++ b/vitest.shared.ts @@ -11,14 +11,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import type { JscConfig } from '@swc/core'; -import { transform } from '@swc/core'; import tsconfigPaths from 'vite-tsconfig-paths'; -import type { Plugin } from 'vitest/config'; import { defineConfig, mergeConfig } from 'vitest/config'; type VitestPackageOptions = { @@ -29,46 +25,8 @@ type VitestPackageOptions = { const repoRoot = fileURLToPath(new URL('.', import.meta.url)); -/** - * Compiles source files with the same SWC React Compiler settings as the package build, so tests exercise the - * memoized output that is published instead of the uncompiled source. - */ -function reactCompilerPlugin(): Plugin { - const swcrc = JSON.parse(readFileSync(resolve(repoRoot, '.swcrc'), 'utf8')) as { - jsc: JscConfig; - exclude?: string[]; - }; - const { baseUrl: _baseUrl, ...jsc } = swcrc.jsc; - const excludePatterns = (swcrc.exclude ?? []).map((pattern) => new RegExp(pattern)); - - return { - name: 'perses:react-compiler', - enforce: 'pre', - async transform(code, id) { - const filename = id.split('?')[0] ?? id; - if ( - !/\.tsx?$/.test(filename) || - filename.includes('/node_modules/') || - !filename.startsWith(repoRoot) || - excludePatterns.some((pattern) => pattern.test(filename)) - ) { - return null; - } - - const output = await transform(code, { - filename, - swcrc: false, - configFile: false, - sourceMaps: true, - jsc, - }); - return { code: output.code, map: output.map }; - }, - }; -} - const sharedConfig = defineConfig({ - plugins: [reactCompilerPlugin(), tsconfigPaths({ projects: [resolve(repoRoot, 'tsconfig.vitest.json')] })], + plugins: [tsconfigPaths({ projects: [resolve(repoRoot, 'tsconfig.vitest.json')] })], resolve: { alias: { 'use-resize-observer': 'use-resize-observer/polyfilled',