diff --git a/packages/angular-table/tests/adapter-lifecycle.test.ts b/packages/angular-table/tests/adapter-lifecycle.test.ts new file mode 100644 index 0000000000..2e3a5f95c8 --- /dev/null +++ b/packages/angular-table/tests/adapter-lifecycle.test.ts @@ -0,0 +1,356 @@ +import { Component, effect, signal } from '@angular/core' +import { By } from '@angular/platform-browser' +import { TestBed } from '@angular/core/testing' +import { createAtom } from '@tanstack/angular-store' +import { describe, expect, test, vi } from 'vitest' +import { + TanStackTable, + TanStackTableCell, + TanStackTableHeader, + createTableHook, + injectTable, + stockFeatures, +} from '../src' +import type { ColumnDef, RowSelectionState, TableOptions } from '../src' + +describe('Angular adapter lifecycle and option ownership', () => { + type Data = { id: string; title: string } + + const data: Array = [{ id: '1', title: 'Title' }] + const columns: Array> = [ + { + id: 'id', + accessorKey: 'id', + }, + { + id: 'title', + accessorKey: 'title', + }, + ] + + test('unsubscribes wrapped external atoms when the host is destroyed', () => { + const rowSelectionAtom = createAtom({}) + const subscribeSpy = vi.spyOn(rowSelectionAtom, 'subscribe') + + @Component({ + standalone: true, + template: ``, + }) + class HostComponent { + readonly table = injectTable(() => ({ + data, + columns, + features: stockFeatures, + getRowId: (row) => row.id, + atoms: { + rowSelection: rowSelectionAtom, + }, + })) + } + + const fixture = TestBed.createComponent(HostComponent) + const table = fixture.componentInstance.table + + expect(table.atoms.rowSelection.get()).toEqual({}) + expect(subscribeSpy).toHaveBeenCalledTimes(1) + + const subscription = subscribeSpy.mock.results[0]!.value + const unsubscribeSpy = vi.spyOn(subscription, 'unsubscribe') + + rowSelectionAtom.set({ 1: true }) + TestBed.tick() + expect(table.atoms.rowSelection.get()).toEqual({ 1: true }) + + fixture.destroy() + + expect(unsubscribeSpy).toHaveBeenCalledTimes(1) + + rowSelectionAtom.set({}) + expect(table.atoms.rowSelection.get()).toEqual({ 1: true }) + }) + + test('preserves the last controlled value when ownership is released', () => { + const controlledState = signal< + { rowSelection: RowSelectionState } | undefined + >({ + rowSelection: { 1: true }, + }) + const table = TestBed.runInInjectionContext(() => + injectTable(() => ({ + data, + columns, + features: stockFeatures, + getRowId: (row) => row.id, + state: controlledState(), + })), + ) + + expect(table.atoms.rowSelection.get()).toEqual({ 1: true }) + TestBed.tick() + + controlledState.set(undefined) + TestBed.tick() + expect(table.atoms.rowSelection.get()).toEqual({ 1: true }) + + table.getRow('1').toggleSelected(false) + TestBed.tick() + expect(table.atoms.rowSelection.get()).toEqual({}) + + controlledState.set({ rowSelection: { 1: true } }) + TestBed.tick() + expect(table.atoms.rowSelection.get()).toEqual({ 1: true }) + + table.getRow('1').toggleSelected(false) + TestBed.tick() + expect(table.atoms.rowSelection.get()).toEqual({ 1: true }) + + controlledState.set(undefined) + TestBed.tick() + expect(table.atoms.rowSelection.get()).toEqual({}) + }) + + test('bridges an external atom in both directions and gives it precedence', () => { + const rowSelectionAtom = createAtom({ 2: true }) + const controlledState = signal({ 1: true }) + const table = TestBed.runInInjectionContext(() => + injectTable(() => ({ + data, + columns, + features: stockFeatures, + getRowId: (row) => row.id, + atoms: { + rowSelection: rowSelectionAtom, + }, + state: { + rowSelection: controlledState(), + }, + })), + ) + + expect(table.atoms.rowSelection.get()).toEqual({ 2: true }) + TestBed.tick() + + controlledState.set({ 3: true }) + TestBed.tick() + expect(table.atoms.rowSelection.get()).toEqual({ 2: true }) + + rowSelectionAtom.set({ 1: true, 2: true }) + TestBed.tick() + expect(table.atoms.rowSelection.get()).toEqual({ + 1: true, + 2: true, + }) + + table.getRow('1').toggleSelected(false) + TestBed.tick() + expect(rowSelectionAtom.get()).toEqual({ 2: true }) + expect(table.atoms.rowSelection.get()).toEqual({ 2: true }) + }) + + test('coalesces rapid controlled updates into one reactive publication', () => { + const controlledState = signal({}) + const table = TestBed.runInInjectionContext(() => + injectTable(() => ({ + data, + columns, + features: stockFeatures, + getRowId: (row) => row.id, + state: { + rowSelection: controlledState(), + }, + })), + ) + const stateCaptor = vi.fn<(state: RowSelectionState) => void>() + + TestBed.runInInjectionContext(() => { + effect(() => stateCaptor(table.atoms.rowSelection.get())) + }) + + TestBed.tick() + + controlledState.set({ 1: true }) + controlledState.set({ 1: true, 2: true }) + controlledState.set({ 2: true }) + TestBed.tick() + + expect(stateCaptor.mock.calls).toEqual([[{}], [{ 2: true }]]) + }) + + test('synchronizes dynamic columns, callbacks, and metadata', () => { + const dynamicColumns = signal(columns) + const getRowId = signal<(row: Data) => string>((row) => row.id) + const meta = signal({ label: 'initial' }) + const table = TestBed.runInInjectionContext(() => + injectTable(() => ({ + data, + columns: dynamicColumns(), + features: stockFeatures, + getRowId: getRowId(), + meta: meta(), + })), + ) + + expect(table.getAllLeafColumns().map((column) => column.id)).toEqual([ + 'id', + 'title', + ]) + expect(table.getRowId(data[0]!, 0)).toBe('1') + expect(table.options.meta).toEqual({ label: 'initial' }) + TestBed.tick() + + dynamicColumns.set([columns[1]!]) + getRowId.set((row) => `row-${row.id}`) + meta.set({ label: 'updated' }) + TestBed.tick() + + expect(table.getAllLeafColumns().map((column) => column.id)).toEqual([ + 'title', + ]) + expect(table.getRowId(data[0]!, 0)).toBe('row-1') + expect(table.options.meta).toEqual({ label: 'updated' }) + }) + + test('attaches createTableHook components to the matching table objects', () => { + const TableToolbar = () => 'table toolbar' + const CellBadge = () => 'cell badge' + const HeaderBadge = () => 'header badge' + const { injectAppTable } = createTableHook({ + features: stockFeatures, + tableComponents: { TableToolbar }, + cellComponents: { CellBadge }, + headerComponents: { HeaderBadge }, + }) + const table = TestBed.runInInjectionContext(() => + injectAppTable( + () => + ({ + data, + columns, + getRowId: (row) => row.id, + }) satisfies Omit< + TableOptions, + 'features' + >, + ), + ) + + const cell = table.getRow('1').getAllCells()[0]! + const header = table.getFlatHeaders()[0]! + + expect(table.TableToolbar).toBe(TableToolbar) + expect(table.appCell(cell)).toBe(cell) + expect(table.appCell(cell).CellBadge).toBe(CellBadge) + expect(table.appHeader(header)).toBe(header) + expect(table.appHeader(header).HeaderBadge).toBe(HeaderBadge) + expect(table.appFooter(header)).toBe(header) + expect(table.appFooter(header).HeaderBadge).toBe(HeaderBadge) + }) + + test('provides createTableHook table, cell, and header contexts through DI', () => { + const TableToolbar = () => 'table toolbar' + const CellBadge = () => 'cell badge' + const HeaderBadge = () => 'header badge' + const { + injectAppTable, + injectTableContext, + injectTableCellContext, + injectTableHeaderContext, + } = createTableHook({ + features: stockFeatures, + tableComponents: { TableToolbar }, + cellComponents: { CellBadge }, + headerComponents: { HeaderBadge }, + }) + + @Component({ + selector: 'app-table-context-consumer', + standalone: true, + template: ``, + }) + class TableContextConsumer { + readonly table = injectTableContext() + } + + @Component({ + selector: 'app-cell-context-consumer', + standalone: true, + template: ``, + }) + class CellContextConsumer { + readonly cell = injectTableCellContext() + } + + @Component({ + selector: 'app-header-context-consumer', + standalone: true, + template: ``, + }) + class HeaderContextConsumer { + readonly header = injectTableHeaderContext() + } + + @Component({ + standalone: true, + imports: [ + TanStackTable, + TanStackTableCell, + TanStackTableHeader, + TableContextConsumer, + CellContextConsumer, + HeaderContextConsumer, + ], + template: ` +
+ +
+
+ +
+
+ +
+ `, + }) + class HostComponent { + readonly table = injectAppTable( + () => + ({ + data, + columns, + getRowId: (row) => row.id, + }) satisfies Omit< + TableOptions, + 'features' + >, + ) + + get cell() { + return this.table.getRow('1').getAllCells()[0]! + } + + get header() { + return this.table.getFlatHeaders()[0]! + } + } + + const fixture = TestBed.createComponent(HostComponent) + fixture.detectChanges() + + const tableConsumer = fixture.debugElement.query( + By.directive(TableContextConsumer), + ).componentInstance as TableContextConsumer + const cellConsumer = fixture.debugElement.query( + By.directive(CellContextConsumer), + ).componentInstance as CellContextConsumer + const headerConsumer = fixture.debugElement.query( + By.directive(HeaderContextConsumer), + ).componentInstance as HeaderContextConsumer + + expect(tableConsumer.table()).toBe(fixture.componentInstance.table) + expect(tableConsumer.table().TableToolbar).toBe(TableToolbar) + expect(cellConsumer.cell()).toBe(fixture.componentInstance.cell) + expect(cellConsumer.cell().CellBadge).toBe(CellBadge) + expect(headerConsumer.header()).toBe(fixture.componentInstance.header) + expect(headerConsumer.header().HeaderBadge).toBe(HeaderBadge) + }) +}) diff --git a/packages/angular-table/tests/angularReactivityFeature.test.ts b/packages/angular-table/tests/angularReactivityFeature.test.ts index 4811498fa9..52074477f8 100644 --- a/packages/angular-table/tests/angularReactivityFeature.test.ts +++ b/packages/angular-table/tests/angularReactivityFeature.test.ts @@ -36,8 +36,7 @@ describe('angularReactivityFeature', () => { } describe('Integration', () => { - // TODO this switches between 1 and 2 calls on every other run, so it's not a reliable test - test('methods within effect will be re-trigger when options/state changes', () => { + test('effects track their state slices and row-model instance changes', () => { const data = signal>([{ id: '1', title: 'Title' }]) const table = createTestTable(data) const isSelectedRow1Captor = vi.fn<(val: boolean) => void>() @@ -95,6 +94,7 @@ describe('angularReactivityFeature', () => { expect(isSelectedRow1Captor.mock.calls).toEqual([[false], [true], [true]]) expect(cellGetValueCaptor.mock.calls).toEqual([['1'], ['1']]) + expect(cellGetValueMemoizedCaptor.mock.calls).toEqual([['1']]) expect(columnIsVisibleCaptor.mock.calls).toEqual([ [true], [true], @@ -142,7 +142,8 @@ describe('angularReactivityFeature', () => { test('table store can be subscribed from another reactive effect', () => { const table = createTestTable() - const tableStateCaptor = vi.fn() + const tableStateCaptor = + vi.fn<(state: ReturnType) => void>() TestBed.runInInjectionContext(() => { effect((onCleanup) => { @@ -154,7 +155,13 @@ describe('angularReactivityFeature', () => { }) }) - expect(() => TestBed.tick()).not.toThrow() + TestBed.tick() + table.toggleAllRowsSelected(true) + TestBed.tick() + + expect( + tableStateCaptor.mock.calls.map(([state]) => state.rowSelection), + ).toEqual([{}, { 1: true }]) }) test('table state reacts to every external signal state update', () => { @@ -211,9 +218,10 @@ describe('angularReactivityFeature', () => { ) expect(table.atoms.pagination.get().pageSize).toBe(20) - expect(JSON.stringify(table.store.get(), null, 2)).toContain( - '"pageSize": 20', - ) + expect(table.store.get().pagination).toEqual({ + pageIndex: 0, + pageSize: 20, + }) }) test('table state reacts to internal table state updates', () => { @@ -250,9 +258,12 @@ describe('angularReactivityFeature', () => { TestBed.tick() expect(pageSizeCaptor.mock.calls).toEqual([[20], [50], [100]]) - expect(stateJsonCaptor.mock.calls.at(-1)?.[0]).toContain( - '"pageSize": 100', - ) + expect( + JSON.parse(stateJsonCaptor.mock.calls.at(-1)![0]).pagination, + ).toEqual({ + pageIndex: 0, + pageSize: 100, + }) }) test('table state property reads only track the accessed slice', () => { @@ -288,7 +299,9 @@ describe('angularReactivityFeature', () => { expect(pageSizeCaptor.mock.calls).toEqual([[20]]) expect(stateJsonCaptor).toHaveBeenCalledTimes(2) - expect(stateJsonCaptor.mock.calls.at(-1)?.[0]).toContain('"rowSelection"') + expect( + JSON.parse(stateJsonCaptor.mock.calls.at(-1)![0]).rowSelection, + ).toEqual({ 1: true }) }) test('stock feature table exposes full initial state and updates json state', () => { @@ -319,13 +332,22 @@ describe('angularReactivityFeature', () => { TestBed.tick() expect(table.atoms.pagination.get().pageSize).toBe(20) expect(table.atoms.columnOrder.get()).toEqual(['id', 'title']) - expect(stateJsonCaptor.mock.calls.at(-1)?.[0]).toContain('"pageSize": 20') + expect(JSON.parse(stateJsonCaptor.mock.calls.at(-1)![0])).toMatchObject({ + columnOrder: ['id', 'title'], + columnPinning: { start: ['id'], end: [] }, + pagination: { pageIndex: 0, pageSize: 20 }, + }) table.setPageSize(50) TestBed.tick() expect(table.atoms.pagination.get().pageSize).toBe(50) - expect(stateJsonCaptor.mock.calls.at(-1)?.[0]).toContain('"pageSize": 50') + expect( + JSON.parse(stateJsonCaptor.mock.calls.at(-1)![0]).pagination, + ).toEqual({ + pageIndex: 0, + pageSize: 50, + }) }) }) }) diff --git a/packages/angular-table/tests/injectTable.test.ts b/packages/angular-table/tests/injectTable.test.ts index cf79c2f110..aee244543d 100644 --- a/packages/angular-table/tests/injectTable.test.ts +++ b/packages/angular-table/tests/injectTable.test.ts @@ -1,4 +1,3 @@ -import { isProxy } from 'node:util/types' import { describe, expect, test, vi } from 'vitest' import { ChangeDetectionStrategy, @@ -8,16 +7,19 @@ import { signal, } from '@angular/core' import { TestBed } from '@angular/core/testing' +import { By } from '@angular/platform-browser' import { ColumnDef, createPaginatedRowModel, stockFeatures, } from '@tanstack/table-core' -import { RowModel, injectTable } from '../src' +import { injectTable } from '../src' import type { PaginationState } from '../src' describe('injectTable', () => { - test('should support required signal inputs', () => { + test('should support required signal inputs', async () => { + type Data = { id: string; title: string } + @Component({ selector: 'app-table', template: ``, @@ -25,27 +27,53 @@ describe('injectTable', () => { changeDetection: ChangeDetectionStrategy.OnPush, }) class TableComponent { - data = input.required>() + data = input.required>() table = injectTable(() => ({ data: this.data(), features: stockFeatures, columns: [], + getRowId: (row) => row.id, })) } @Component({ selector: 'app-root', imports: [TableComponent], - template: ` `, + template: ``, changeDetection: ChangeDetectionStrategy.OnPush, }) - class RootComponent {} + class RootComponent { + readonly data = signal>([{ id: '1', title: 'First' }]) + } const fixture = TestBed.createComponent(RootComponent) fixture.detectChanges() + await fixture.whenRenderingDone() + + const tableComponent = fixture.debugElement.query( + By.directive(TableComponent), + ).componentInstance as TableComponent - fixture.whenRenderingDone() + expect( + tableComponent.table.getRowModel().rows.map((row) => row.original), + ).toEqual([{ id: '1', title: 'First' }]) + TestBed.tick() + + fixture.componentInstance.data.set([ + { id: '1', title: 'Updated' }, + { id: '2', title: 'Second' }, + ]) + fixture.detectChanges() + TestBed.tick() + await fixture.whenRenderingDone() + + expect( + tableComponent.table.getRowModel().rows.map((row) => row.original), + ).toEqual([ + { id: '1', title: 'Updated' }, + { id: '2', title: 'Second' }, + ]) }) describe('Proxy table', () => { @@ -64,26 +92,19 @@ describe('injectTable', () => { })), ) - test('table is proxy', () => { - expect(isProxy(table)).toBe(true) - }) - test('supports "in" operator', () => { - expect('_features' in table).toBe(true) + expect('atoms' in table).toBe(true) expect('options' in table).toBe(true) expect('notFound' in table).toBe(false) }) test('supports "Object.keys"', () => { const keys = Object.keys(table) - expect(keys.some((k) => ['state', 'getRowModel'].includes(k))) + expect(keys).toEqual(expect.arrayContaining(['options', 'getRowModel'])) }) test('Row model is reactive', () => { - const coreRowModelFn = - vi.fn<(model: RowModel) => void>() - const rowModelFn = - vi.fn<(model: RowModel) => void>() + const rowCounts = vi.fn<(count: number) => void>() const pagination = signal({ pageSize: 5, pageIndex: 0, @@ -112,8 +133,8 @@ describe('injectTable', () => { }, })) - effect(() => coreRowModelFn(table.getCoreRowModel())) - effect(() => rowModelFn(table.getRowModel())) + const initialCoreRowModel = table.getCoreRowModel() + effect(() => rowCounts(table.getRowModel().rows.length)) TestBed.tick() @@ -121,15 +142,8 @@ describe('injectTable', () => { TestBed.tick() - // TODO: pagination state update twice during first table construct - // optionsStore is a signal -> so if updated with state in queuemicrotask will trigger twice - expect(coreRowModelFn).toHaveBeenCalledTimes(2) - expect(coreRowModelFn.mock.calls[0]![0].rows.length).toEqual(10) - expect(coreRowModelFn.mock.calls[0]![0].rows.length).toEqual(10) - - expect(rowModelFn).toHaveBeenCalledTimes(2) - expect(rowModelFn.mock.calls[0]![0].rows.length).toEqual(5) - expect(rowModelFn.mock.calls[1]![0].rows.length).toEqual(3) + expect(rowCounts.mock.calls).toEqual([[5], [3]]) + expect(table.getCoreRowModel()).toBe(initialCoreRowModel) }) }) }) diff --git a/packages/react-table/package.json b/packages/react-table/package.json index 6bbecfc097..b1eaa58de2 100644 --- a/packages/react-table/package.json +++ b/packages/react-table/package.json @@ -54,6 +54,7 @@ }, "devDependencies": { "@eslint-react/eslint-plugin": "^5.9.2", + "@testing-library/react": "^16.3.2", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.3", diff --git a/packages/react-table/src/FlexRender.tsx b/packages/react-table/src/FlexRender.tsx index d345d06cec..80662f348a 100644 --- a/packages/react-table/src/FlexRender.tsx +++ b/packages/react-table/src/FlexRender.tsx @@ -46,11 +46,11 @@ export function flexRender( Comp: Renderable, props: TProps, ): ReactNode | JSX.Element { - return !Comp ? null : isReactComponent(Comp) ? ( - - ) : ( - Comp - ) + if (Comp === null || Comp === undefined) { + return null + } + + return isReactComponent(Comp) ? : Comp } /** diff --git a/packages/react-table/tests/adapterReactivity.test.tsx b/packages/react-table/tests/adapterReactivity.test.tsx new file mode 100644 index 0000000000..a2af8c9a33 --- /dev/null +++ b/packages/react-table/tests/adapterReactivity.test.tsx @@ -0,0 +1,453 @@ +// @vitest-environment jsdom + +import * as React from 'react' +import { + act, + cleanup, + fireEvent, + screen, + render as testingLibraryRender, +} from '@testing-library/react' +import { + createPaginatedRowModel, + stockFeatures, + tableFeatures, +} from '@tanstack/table-core' +import { createAtom } from '@tanstack/react-store' +import { afterEach, describe, expect, test, vi } from 'vitest' +import { useTable } from '../src' +import type { + ColumnDef, + PaginationState, + RowSelectionState, +} from '@tanstack/table-core' +import type { ReactTable } from '../src' + +type Data = { + id: string + title: string +} + +const columns: Array> = [ + { + id: 'id', + header: 'Id', + accessorKey: 'id', + cell: (context) => context.getValue(), + }, + { + id: 'title', + header: 'Title', + accessorKey: 'title', + cell: (context) => context.getValue(), + }, +] + +const paginatedFeatures = tableFeatures({ + ...stockFeatures, + paginatedRowModel: createPaginatedRowModel(), +}) +const paginatedColumns: Array> = [ + { + id: 'id', + header: 'Id', + accessorKey: 'id', + cell: (context) => context.getValue(), + }, + { + id: 'title', + header: 'Title', + accessorKey: 'title', + cell: (context) => context.getValue(), + }, +] + +let renderedView: ReturnType | undefined + +function render(element: React.ReactNode) { + renderedView = testingLibraryRender(element) + return renderedView +} + +function text(name: string) { + return screen.getByRole('status', { name }).textContent +} + +function click(name: string) { + fireEvent.click(screen.getByRole('button', { name })) +} + +function unmount() { + renderedView!.unmount() + renderedView = undefined +} + +afterEach(() => { + cleanup() + renderedView = undefined + vi.restoreAllMocks() +}) + +// Adapter contract only: React/store ownership, subscriptions, lifecycle, and +// option refreshes. Row-model algorithms remain covered by table-core. +describe('React adapter reactivity and lifecycle', () => { + test('exposes React adapter APIs through the returned table surface', () => { + function TableHarness() { + const table = useTable( + { + data: [{ id: '1', title: 'Title' }], + features: stockFeatures, + columns, + getRowId: (row) => row.id, + }, + () => null, + ) + + return ( + + {JSON.stringify({ + hasOptions: 'options' in table, + hasState: 'state' in table, + hasRowModel: 'getRowModel' in table, + hasSubscribe: 'Subscribe' in table, + hasFlexRender: 'FlexRender' in table, + keys: Object.keys(table), + })} + + ) + } + + render() + + const surface = JSON.parse(text('Table surface')) as { + hasOptions: boolean + hasState: boolean + hasRowModel: boolean + hasSubscribe: boolean + hasFlexRender: boolean + keys: Array + } + + expect(surface).toEqual({ + hasOptions: true, + hasState: true, + hasRowModel: true, + hasSubscribe: true, + hasFlexRender: true, + keys: expect.any(Array), + }) + expect(surface.keys).toEqual( + expect.arrayContaining([ + 'options', + 'state', + 'Subscribe', + 'FlexRender', + 'getRowModel', + ]), + ) + }) + + test('updates the paginated row model without invalidating the core row model', () => { + const data = Array.from({ length: 10 }, (_, index) => ({ + id: String(index), + title: `Title ${index}`, + })) + const coreRowModelCaptor = vi.fn() + const rowModelCaptor = vi.fn() + function TableHarness() { + const [pagination, setPagination] = React.useState({ + pageIndex: 0, + pageSize: 5, + }) + const table = useTable( + { + data, + features: paginatedFeatures, + columns: paginatedColumns, + getRowId: (row) => row.id, + state: { pagination }, + onPaginationChange: setPagination, + }, + (state) => state.pagination, + ) + const coreRowModel = table.getCoreRowModel() + const rowModel = table.getRowModel() + + coreRowModelCaptor(coreRowModel) + rowModelCaptor(rowModel) + + return ( + <> + + {coreRowModel.rows.map((row) => row.id).join(',')} + + + {rowModel.rows.map((row) => row.id).join(',')} + + + + ) + } + + render() + + expect(text('Core row IDs')).toBe('0,1,2,3,4,5,6,7,8,9') + expect(text('Page row IDs')).toBe('0,1,2,3,4') + + act(() => { + click('Set page size to 3') + }) + + expect(text('Core row IDs')).toBe('0,1,2,3,4,5,6,7,8,9') + expect(text('Page row IDs')).toBe('0,1,2') + expect(coreRowModelCaptor).toHaveBeenCalledTimes(2) + expect(rowModelCaptor).toHaveBeenCalledTimes(2) + expect(coreRowModelCaptor.mock.calls[0]![0]).toBe( + coreRowModelCaptor.mock.calls[1]![0], + ) + expect(rowModelCaptor.mock.calls[0]![0].rows).toHaveLength(5) + expect(rowModelCaptor.mock.calls[1]![0].rows).toHaveLength(3) + }) + + test('bridges external atoms through both Subscribe source overloads', () => { + const rowSelectionAtom = createAtom({}) + + function TableHarness() { + const table = useTable( + { + data: [{ id: '1', title: 'Title' }], + features: stockFeatures, + columns, + getRowId: (row) => row.id, + atoms: { + rowSelection: rowSelectionAtom, + }, + }, + () => null, + ) + + return ( + <> + Boolean(selection['1'])} + > + {(selected) => ( + + {String(selected)} + + )} + + + {(selection) => { + return ( + + {JSON.stringify(selection)} + + ) + }} + + + + ) + } + + render() + + expect(text('External row selected')).toBe('false') + expect(text('External row selection')).toBe('{}') + + act(() => { + rowSelectionAtom.set({ 1: true }) + }) + + expect(text('External row selected')).toBe('true') + expect(text('External row selection')).toBe('{"1":true}') + + act(() => { + click('Toggle external row') + }) + + expect(rowSelectionAtom.get()).toEqual({}) + expect(text('External row selected')).toBe('false') + expect(text('External row selection')).toBe('{}') + }) + + test('stops root and isolated React observers after unmount', () => { + const rowSelectionAtom = createAtom({}) + const rootStoreSelectorCaptor = vi.fn() + const isolatedStoreCaptor = vi.fn() + const captureTable = + vi.fn<(table: ReactTable) => void>() + + function TableHarness() { + const table = useTable( + { + data: [{ id: '1', title: 'Title' }], + features: stockFeatures, + columns, + getRowId: (row) => row.id, + atoms: { + rowSelection: rowSelectionAtom, + }, + }, + (state) => { + rootStoreSelectorCaptor({ + rowSelection: state.rowSelection, + pageSize: state.pagination.pageSize, + }) + return Boolean(state.rowSelection['1']) + }, + ) + + React.useLayoutEffect(() => { + captureTable(table) + }, [table]) + + return ( + ({ + selected: Boolean(state.rowSelection['1']), + pageSize: state.pagination.pageSize, + })} + > + {(snapshot) => { + isolatedStoreCaptor(snapshot) + return ( + + {String(snapshot.selected)} + + ) + }} + + ) + } + + render() + + act(() => { + rowSelectionAtom.set({ 1: true }) + }) + + expect(text('Lifecycle selection')).toBe('true') + + unmount() + + const rootCallsAfterUnmount = rootStoreSelectorCaptor.mock.calls.length + const isolatedCallsAfterUnmount = isolatedStoreCaptor.mock.calls.length + const table = captureTable.mock.lastCall![0] + + act(() => { + rowSelectionAtom.set({}) + table.setPageSize(25) + }) + + expect(rootStoreSelectorCaptor).toHaveBeenCalledTimes(rootCallsAfterUnmount) + expect(isolatedStoreCaptor).toHaveBeenCalledTimes(isolatedCallsAfterUnmount) + }) + + test('refreshes data, columns, and table options together on the next render', async () => { + type DynamicData = { + id: string + alternateId: string + title: string + status: string + } + + const initialColumns: Array> = + [ + { id: 'title', header: 'Title', accessorKey: 'title' }, + { id: 'missing', header: 'Missing', accessorFn: () => undefined }, + ] + const updatedColumns: Array> = + [ + { id: 'status', header: 'Status', accessorKey: 'status' }, + { id: 'missing', header: 'Absent', accessorFn: () => undefined }, + ] + const initialData: Array = [ + { + id: '1', + alternateId: 'alternate-1', + title: 'Alpha', + status: 'draft', + }, + ] + const updatedData: Array = [ + { + id: '2', + alternateId: 'alternate-2', + title: 'Beta', + status: 'ready', + }, + ] + const renderCaptor = vi.fn<(version: number) => void>() + + function TableHarness() { + const [version, setVersion] = React.useState(0) + const isUpdated = version === 1 + const table = useTable({ + data: isUpdated ? updatedData : initialData, + features: stockFeatures, + columns: isUpdated ? updatedColumns : initialColumns, + getRowId: isUpdated ? (row) => row.alternateId : (row) => row.id, + renderFallbackValue: isUpdated + ? 'updated fallback' + : 'initial fallback', + autoResetAll: false, + }) + const row = table.getRowModel().rows[0]! + const cells = row.getAllCells() + + renderCaptor(version) + + return ( + <> + {row.id} + + {table + .getAllLeafColumns() + .map((column) => column.id) + .join(',')} + + + {table + .getAllLeafColumns() + .map((column) => column.columnDef.header) + .join(',')} + + + {String(cells[0]!.getValue())} + + + {String(cells[1]!.renderValue())} + + + + ) + } + + render() + + expect(text('Dynamic row ID')).toBe('1') + expect(text('Dynamic column IDs')).toBe('title,missing') + expect(text('Dynamic headers')).toBe('Title,Missing') + expect(text('Dynamic cell value')).toBe('Alpha') + expect(text('Dynamic fallback')).toBe('initial fallback') + + await act(async () => { + click('Refresh options') + await Promise.resolve() + }) + + expect(text('Dynamic row ID')).toBe('alternate-2') + expect(text('Dynamic column IDs')).toBe('status,missing') + expect(text('Dynamic headers')).toBe('Status,Absent') + expect(text('Dynamic cell value')).toBe('ready') + expect(text('Dynamic fallback')).toBe('updated fallback') + expect(renderCaptor.mock.calls).toEqual([[0], [1]]) + }) +}) diff --git a/packages/react-table/tests/createTableHook.test.tsx b/packages/react-table/tests/createTableHook.test.tsx new file mode 100644 index 0000000000..d5f7fe966e --- /dev/null +++ b/packages/react-table/tests/createTableHook.test.tsx @@ -0,0 +1,246 @@ +// @vitest-environment jsdom + +import * as React from 'react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { rowSelectionFeature, tableFeatures } from '@tanstack/table-core' +import { afterEach, describe, expect, test, vi } from 'vitest' +import { createTableHook, createTableHookContexts } from '../src' + +type Person = { + id: string + name: string +} + +const features = tableFeatures({ + rowSelectionFeature, +}) +const contexts = createTableHookContexts() + +function RowCount() { + const table = contexts.useTableContext() + + return ( + + {table.getRowModel().rows.length} + + ) +} + +function NameCell() { + const cell = contexts.useCellContext() + + return {cell.getValue().toUpperCase()} +} + +function NameHeader() { + const header = contexts.useHeaderContext() + + return Header {header.column.id} +} + +const appTable = createTableHook({ + features, + tableContext: contexts.tableContext, + cellContext: contexts.cellContext, + headerContext: contexts.headerContext, + tableComponents: { RowCount }, + cellComponents: { NameCell }, + headerComponents: { NameHeader }, +}) +const columnHelper = appTable.createAppColumnHelper() +const columns = columnHelper.columns([ + columnHelper.accessor('name', { + id: 'name', + header: ({ header }) => , + cell: ({ cell }) => , + footer: 'Name footer', + }), +]) + +function text(name: string) { + return screen.getByRole('status', { name }).textContent +} + +function click(name: string) { + fireEvent.click(screen.getByRole('button', { name })) +} + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +describe('createTableHook runtime', () => { + test('binds components, contexts, FlexRender, and state selectors', () => { + function TableHarness() { + const table = appTable.useAppTable( + { + data: [{ id: '1', name: 'Ada' }], + columns, + getRowId: (row) => row.id, + }, + () => null, + ) + const header = table.getHeaderGroups()[0]!.headers[0]! + const cell = table.getRowModel().rows[0]!.getAllCells()[0]! + + return ( + Object.keys(state.rowSelection).length} + > + {(selectedCount) => ( + <> + + {selectedCount} + + {(extendedHeader) => } + + Boolean(state.rowSelection[cell.row.id])} + > + {(extendedCell, selected) => ( + <> + + + {String(selected)} + + + )} + + + {(extendedFooter) => } + + + + )} + + ) + } + + render() + + expect(text('Bound row count')).toBe('1') + expect(screen.getByText('Header name')).toBeTruthy() + expect(screen.getByText('ADA')).toBeTruthy() + expect(screen.getByText('Name footer')).toBeTruthy() + expect(text('App table selection')).toBe('0') + expect(text('App cell selection')).toBe('false') + + act(() => { + click('Select app row') + }) + + expect(text('App table selection')).toBe('1') + expect(text('App cell selection')).toBe('true') + }) + + test('keeps App wrappers mounted while their contexts receive new table objects', () => { + const mountCaptor = vi.fn() + const unmountCaptor = vi.fn() + + function StatefulCell() { + const cell = appTable.useCellContext() + const [draft, setDraft] = React.useState('initial') + + React.useEffect(() => { + mountCaptor() + return () => unmountCaptor() + }, []) + + return ( + <> + + setDraft(event.target.value)} + /> + {cell.getValue()} + + ) + } + + function TableHarness() { + const [updated, setUpdated] = React.useState(false) + const table = appTable.useAppTable( + { + data: [{ id: '1', name: updated ? 'Grace' : 'Ada' }], + columns, + getRowId: (row) => row.id, + }, + () => null, + ) + const header = table.getHeaderGroups()[0]!.headers[0]! + const cell = table.getRowModel().rows[0]!.getAllCells()[0]! + + return ( + <> + + + {() => ( + + {() => ( + + {() => } + + )} + + )} + + + + + ) + } + + render() + + const input = screen.getByRole('textbox', { name: 'Cell draft' }) + fireEvent.change(input, { target: { value: 'edited' } }) + + expect(text('Latest cell value')).toBe('Ada') + expect(mountCaptor).toHaveBeenCalledOnce() + + act(() => { + click('Refresh app table') + }) + + expect(screen.getByRole('textbox', { name: 'Cell draft' })).toBe(input) + expect((input as HTMLInputElement).value).toBe('edited') + expect(text('Latest cell value')).toBe('Grace') + expect(mountCaptor).toHaveBeenCalledOnce() + expect(unmountCaptor).not.toHaveBeenCalled() + }) + + test('throws focused errors when context hooks are used outside wrappers', () => { + function MissingTableContext() { + appTable.useTableContext() + return null + } + + function MissingCellContext() { + appTable.useCellContext() + return null + } + + function MissingHeaderContext() { + appTable.useHeaderContext() + return null + } + + expect(() => render()).toThrow( + '`useTableContext` must be used within an `AppTable` component', + ) + cleanup() + expect(() => render()).toThrow( + '`useCellContext` must be used within an `AppCell` component', + ) + cleanup() + expect(() => render()).toThrow( + '`useHeaderContext` must be used within an `AppHeader` or `AppFooter` component', + ) + }) +}) diff --git a/packages/react-table/tests/ssr.test.tsx b/packages/react-table/tests/ssr.test.tsx new file mode 100644 index 0000000000..0c762ef00e --- /dev/null +++ b/packages/react-table/tests/ssr.test.tsx @@ -0,0 +1,163 @@ +// @vitest-environment node + +import * as React from 'react' +import { renderToStaticMarkup, renderToString } from 'react-dom/server' +import { describe, expect, test, vi } from 'vitest' +import { flexRender, stockFeatures, useTable } from '../src' +import type { ColumnDef } from '../src' + +type Person = { + id: string + name: string +} + +const columns: Array> = [ + { + id: 'name', + accessorKey: 'name', + header: ({ column }) => {`Header ${column.id}`}, + cell: ({ getValue }) => {`Cell ${String(getValue())}`}, + aggregatedCell: ({ getValue }) => ( + {`Aggregate ${String(getValue())}`} + ), + footer: 'Name footer', + }, +] + +describe('React server rendering', () => { + test('renders useTable state and FlexRender output without a DOM', () => { + function ServerTable() { + const table = useTable({ + data: [{ id: '1', name: 'Ada' }], + features: stockFeatures, + columns, + getRowId: (row) => row.id, + initialState: { + pagination: { + pageIndex: 0, + pageSize: 25, + }, + }, + }) + const header = table.getHeaderGroups()[0]!.headers[0]! + const cell = table.getRowModel().rows[0]!.getAllCells()[0]! + + return ( + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ ) + } + + expect(typeof document).toBe('undefined') + + const markup = renderToString() + + expect(markup).toContain('data-page-size="25"') + expect(markup).toContain('Header name') + expect(markup).toContain('Cell Ada') + expect(markup).toContain('Name footer') + }) + + test('selects aggregate content and suppresses placeholder cells', () => { + function CellModes() { + const table = useTable({ + data: [ + { id: '1', name: 'Ada' }, + { id: '2', name: 'Grace' }, + ], + features: stockFeatures, + columns, + getRowId: (row) => row.id, + }) + const aggregatedCell = table.getRow('1').getAllCells()[0]! + const placeholderCell = table.getRow('2').getAllCells()[0]! + + vi.spyOn(aggregatedCell, 'getIsAggregated').mockReturnValue(true) + vi.spyOn(placeholderCell, 'getIsAggregated').mockReturnValue(false) + vi.spyOn(placeholderCell, 'getIsPlaceholder').mockReturnValue(true) + + return ( + <> +
+ +
+
+ +
+ + ) + } + + expect(renderToStaticMarkup()).toBe( + '
Aggregate Ada
' + + '
', + ) + }) + + test('flexRender handles function, memo, forwardRef, node, and empty renderables', () => { + function FunctionRenderable({ value }: { value: string }) { + return {value} + } + + const MemoRenderable = React.memo(function MemoRenderable({ + value, + }: { + value: string + }) { + return {value} + }) + // eslint-disable-next-line @eslint-react/no-forward-ref -- Covers the React 18 exotic-component shape supported by FlexRender. + const ForwardRefRenderable = React.forwardRef< + HTMLSpanElement, + { value: string } + >(function ForwardRefRenderable({ value }, ref) { + return ( + + {value} + + ) + }) + + const markup = renderToStaticMarkup( + <> + {flexRender(FunctionRenderable, { value: 'function value' })} + {flexRender(MemoRenderable, { value: 'memo value' })} + {flexRender(ForwardRefRenderable, { value: 'forward value' })} + {flexRender(node value, { + value: 'ignored', + })} + {flexRender(0, { value: 'ignored' })} + , + ) + + expect(markup).toBe( + 'function value' + + 'memo value' + + 'forward value' + + 'node value' + + '0', + ) + expect(flexRender(null, {})).toBeNull() + }) +}) diff --git a/packages/react-table/tests/useTable.test.tsx b/packages/react-table/tests/useTable.test.tsx index 0d9f28e921..6505e12508 100644 --- a/packages/react-table/tests/useTable.test.tsx +++ b/packages/react-table/tests/useTable.test.tsx @@ -1,5 +1,13 @@ +// @vitest-environment jsdom + import * as React from 'react' -import { createRoot } from 'react-dom/client' +import { + act, + cleanup, + fireEvent, + screen, + render as testingLibraryRender, +} from '@testing-library/react' import { createPaginatedRowModel, rowPaginationFeature, @@ -10,7 +18,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { useTable } from '../src/useTable' import type { ColumnDef, PaginationState } from '@tanstack/table-core' import type { ReactTable } from '../src/useTable' -import type { Root } from 'react-dom/client' const features = tableFeatures({ rowPaginationFeature, @@ -37,46 +44,36 @@ const IsolatedPaginationSubscriber = React.memo( return ( state.pagination.pageIndex}> {(pageIndex) => ( - {pageIndex} + {pageIndex} )} ) }, ) -let container: HTMLDivElement | undefined -let root: Root | undefined - function render(element: React.ReactNode) { - container = document.createElement('div') - document.body.append(container) - root = createRoot(container) + return testingLibraryRender(element) +} - React.act(() => { - root!.render(element) - }) +function text(name: string) { + return screen.getByRole('status', { name }).textContent } -afterEach(() => { - if (root) { - React.act(() => { - root!.unmount() - }) - } +function click(name: string) { + fireEvent.click(screen.getByRole('button', { name })) +} - container?.remove() - container = undefined - root = undefined +afterEach(() => { + cleanup() vi.restoreAllMocks() }) describe('useTable state subscriptions', () => { - it('reads controlled state from the live table store', () => { + it('renders each controlled update once without a redundant commit render', () => { let harnessRenderCount = 0 let setControlledPagination: React.Dispatch< React.SetStateAction > - let storeSubscribe: ReturnType | undefined function ControlledStateHarness() { harnessRenderCount++ @@ -98,35 +95,24 @@ describe('useTable state subscriptions', () => { (state) => state.pagination.pageIndex, ) - // useSyncExternalStore subscribes after render. Installing the spy here - // distinguishes the root adapter subscription from table construction. - storeSubscribe ??= vi.spyOn(table.store, 'subscribe') - return ( - - {table.state} - + {table.state} ) } render() - expect(storeSubscribe).toHaveBeenCalledTimes(1) expect(harnessRenderCount).toBe(1) - React.act(() => { + act(() => { setControlledPagination!({ pageIndex: 1, pageSize: 10, }) }) - expect( - container?.querySelector('[data-testid="controlled-source-page-index"]') - ?.textContent, - ).toBe('1') + expect(text('Controlled source page index')).toBe('1') expect(harnessRenderCount).toBe(2) - expect(storeSubscribe).toHaveBeenCalledTimes(1) }) it('resolves changes in slice ownership', () => { @@ -155,12 +141,11 @@ describe('useTable state subscriptions', () => { return ( <> - + + {table.state.pageIndex} @@ -169,29 +154,13 @@ describe('useTable state subscriptions', () => { render() - const pageIndex = () => - container?.querySelector('[data-testid="ownership-page-index"]') - ?.textContent - - const toggleOwnership = () => { - const button = container?.querySelector( - '[data-testid="toggle-ownership"]', - ) - button?.click() - } - - const nextPage = () => { - const button = container?.querySelector( - '[data-testid="next-page"]', - ) - button?.click() - } + const pageIndex = () => text('Ownership page index') expect(pageIndex()).toBe('5') expect(harnessRenderCount).toBe(1) - React.act(() => { - toggleOwnership() + act(() => { + click('Toggle ownership') }) // Releasing control exposes the last committed controlled value in the @@ -199,22 +168,22 @@ describe('useTable state subscriptions', () => { expect(pageIndex()).toBe('5') expect(harnessRenderCount).toBe(2) - React.act(() => { - nextPage() + act(() => { + click('Next page') }) expect(pageIndex()).toBe('6') expect(harnessRenderCount).toBe(3) - React.act(() => { - toggleOwnership() + act(() => { + click('Toggle ownership') }) expect(pageIndex()).toBe('5') expect(harnessRenderCount).toBe(4) - React.act(() => { - nextPage() + act(() => { + click('Next page') }) // The controlled prop owns the slice again; base writes are not observed @@ -243,36 +212,27 @@ describe('useTable state subscriptions', () => { return ( <> - + ) } render() - const isolatedPageIndex = () => - container?.querySelector('[data-testid="isolated-page-index"]') - ?.textContent + const isolatedPageIndex = () => text('Isolated page index') expect(isolatedPageIndex()).toBe('5') - React.act(() => { - container - ?.querySelector('[data-action="change-base"]') - ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + act(() => { + click('Change base') }) // The controlled value still wins even though the internal base moved. expect(isolatedPageIndex()).toBe('5') - React.act(() => { - container - ?.querySelector('[data-action="release-control"]') - ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + act(() => { + click('Release control') }) expect(isolatedPageIndex()).toBe('6') @@ -295,14 +255,15 @@ describe('useTable state subscriptions', () => { atoms: { pagination: paginationAtom, }, + autoResetPageIndex: false, }, (state) => state.pagination.pageIndex, ) return ( <> - {table.state} - + {table.state} + {table.getRowModel().rows[0]?.original.id} @@ -313,7 +274,7 @@ describe('useTable state subscriptions', () => { expect(harnessRenderCount).toBe(1) - React.act(() => { + act(() => { paginationAtom.set({ pageIndex: 0, pageSize: 20, @@ -322,21 +283,15 @@ describe('useTable state subscriptions', () => { expect(harnessRenderCount).toBe(1) - React.act(() => { + act(() => { paginationAtom.set({ pageIndex: 1, pageSize: 20, }) }) - expect( - container?.querySelector('[data-testid="external-atom-page-index"]') - ?.textContent, - ).toBe('1') - expect( - container?.querySelector('[data-testid="external-atom-first-row"]') - ?.textContent, - ).toBe('20') + expect(text('External atom page index')).toBe('1') + expect(text('External atom first row')).toBe('20') expect(harnessRenderCount).toBe(2) }) @@ -369,43 +324,39 @@ describe('useTable state subscriptions', () => { state: { pagination, }, + autoResetPageIndex: false, }, (state) => state.pagination.pageIndex, ) return ( <> - {table.state} - + {table.state} + {table.getRowModel().rows[0]?.original.id} + ) } render() - expect( - container?.querySelector('[data-testid="mixed-page-index"]')?.textContent, - ).toBe('3') - expect( - container?.querySelector('[data-testid="mixed-first-row"]')?.textContent, - ).toBe('30') + expect(text('Mixed page index')).toBe('3') + expect(text('Mixed first row')).toBe('30') expect(harnessRenderCount).toBe(1) - React.act(() => { + act(() => { setControlledPagination!({ pageIndex: 7, pageSize: 10, }) }) - expect( - container?.querySelector('[data-testid="mixed-page-index"]')?.textContent, - ).toBe('3') + expect(text('Mixed page index')).toBe('3') expect(harnessRenderCount).toBe(2) - React.act(() => { + act(() => { paginationAtom.set({ pageIndex: 3, pageSize: 20, @@ -414,20 +365,28 @@ describe('useTable state subscriptions', () => { expect(harnessRenderCount).toBe(2) - React.act(() => { + act(() => { paginationAtom.set({ pageIndex: 4, pageSize: 10, }) }) - expect( - container?.querySelector('[data-testid="mixed-page-index"]')?.textContent, - ).toBe('4') - expect( - container?.querySelector('[data-testid="mixed-first-row"]')?.textContent, - ).toBe('40') + expect(text('Mixed page index')).toBe('4') + expect(text('Mixed first row')).toBe('40') expect(harnessRenderCount).toBe(3) + + act(() => { + click('Next page') + }) + + expect(paginationAtom.get()).toEqual({ + pageIndex: 5, + pageSize: 10, + }) + expect(text('Mixed page index')).toBe('5') + expect(text('Mixed first row')).toBe('50') + expect(harnessRenderCount).toBe(4) }) it('does not mutate base state or notify subscribers from a suspended render', () => { @@ -466,7 +425,7 @@ describe('useTable state subscriptions', () => { return ( <> - + {table.state.pageIndex} state.pagination.pageIndex}> {(pageIndex) => ( - {pageIndex} + {pageIndex} )} @@ -488,32 +447,19 @@ describe('useTable state subscriptions', () => { render() - expect( - container?.querySelector('[data-testid="committed-page-index"]') - ?.textContent, - ).toBe('0') + expect(text('Committed page index')).toBe('0') const storeNotifications: Array = [] const subscription = committedTable!.store.subscribe((state) => { storeNotifications.push(state.pagination.pageIndex) }) - React.act(() => { - container?.querySelector('button')?.dispatchEvent( - new MouseEvent('click', { - bubbles: true, - }), - ) + act(() => { + click('Suspend next render') }) // The previous UI is still committed while the transition is suspended. - expect( - container?.querySelector('[data-testid="committed-page-index"]') - ?.textContent, - ).toBe('0') - expect( - container?.querySelector('[data-testid="isolated-page-index"]') - ?.textContent, - ).toBe('0') + expect(text('Committed page index')).toBe('0') + expect(text('Isolated page index')).toBe('0') const committedBasePageIndex = committedTable!.baseAtoms.pagination.get().pageIndex @@ -535,16 +481,20 @@ describe('useTable state subscriptions', () => { }, (state) => state.pagination, ) - latestTable = table + React.useEffect(() => { + latestTable = table + }, [table]) return ( <> - + {table.state.pageIndex} state.pagination.pageIndex}> {(pageIndex) => ( - {pageIndex} + + {pageIndex} + )} @@ -559,23 +509,17 @@ describe('useTable state subscriptions', () => { }) const pagination = { pageIndex: 3, pageSize: 10 } - React.act(() => { + act(() => { latestTable!.setOptions((options) => ({ ...options, state: { ...options.state, pagination }, })) }) - expect(latestTable!.baseAtoms.pagination.get()).toBe(pagination) - expect(latestTable!.store.get().pagination).toBe(pagination) - expect( - container?.querySelector('[data-testid="imperative-page-index"]') - ?.textContent, - ).toBe('3') - expect( - container?.querySelector('[data-testid="imperative-subscriber"]') - ?.textContent, - ).toBe('3') + expect(latestTable!.atoms.pagination.get()).toEqual(pagination) + expect(latestTable!.store.get().pagination).toEqual(pagination) + expect(text('Imperative page index')).toBe('3') + expect(text('Imperative subscriber page index')).toBe('3') expect(notifications).toEqual([3]) subscription.unsubscribe() @@ -623,15 +567,15 @@ describe('useTable state subscriptions', () => { return ( <> - + {table.state.pagination.pageIndex} - + {table.atoms.pagination.get().pageIndex} state.pagination.pageIndex}> {(pageIndex) => ( - {pageIndex} + {pageIndex} )} @@ -639,7 +583,7 @@ describe('useTable state subscriptions', () => { ) } - const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const consoleError = vi.spyOn(console, 'error') render( @@ -647,32 +591,22 @@ describe('useTable state subscriptions', () => { , ) - const pageIndex = () => - container?.querySelector('[data-testid="page-index"]')?.textContent - const atomPageIndex = () => - container?.querySelector('[data-testid="atom-page-index"]')?.textContent - const subscribedPageIndex = () => - container?.querySelector('[data-testid="subscribed-page-index"]') - ?.textContent - const nextPage = () => - container?.querySelector('button')?.dispatchEvent( - new MouseEvent('click', { - bubbles: true, - }), - ) + const pageIndex = () => text('Selected page index') + const atomPageIndex = () => text('Atom page index') + const subscribedPageIndex = () => text('Subscribed page index') expect(pageIndex()).toBe('0') expect(atomPageIndex()).toBe('0') expect(subscribedPageIndex()).toBe('0') - React.act(() => { - nextPage() + act(() => { + click('Next page') }) expect(pageIndex()).toBe('1') expect(atomPageIndex()).toBe('1') expect(subscribedPageIndex()).toBe('1') - React.act(() => { - nextPage() + act(() => { + click('Next page') }) expect(pageIndex()).toBe('2') expect(atomPageIndex()).toBe('2') @@ -737,7 +671,7 @@ describe('useTable state subscriptions', () => { return ( <> - + {pagination.pageIndex} @@ -754,26 +688,18 @@ describe('useTable state subscriptions', () => { ) } - const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const consoleError = vi.spyOn(console, 'error') render() - const controlledPageIndex = () => - container?.querySelector('[data-testid="controlled-page-index"]') - ?.textContent - const isolatedPageIndex = () => - container?.querySelector('[data-testid="isolated-page-index"]') - ?.textContent + const controlledPageIndex = () => text('Controlled page index') + const isolatedPageIndex = () => text('Isolated page index') expect(controlledPageIndex()).toBe('0') expect(isolatedPageIndex()).toBe('0') - React.act(() => { - container?.querySelector('button')?.dispatchEvent( - new MouseEvent('click', { - bubbles: true, - }), - ) + act(() => { + click('Advance three pages') }) expect(controlledPageIndex()).toBe('3') @@ -801,55 +727,40 @@ describe('useTable state subscriptions', () => { return ( <> - {table.state} - + {table.state} + {harnessRenderCount} state.pagination.pageSize}> {(pageSize) => ( - {pageSize} + {pageSize} )} - - + + ) } render() - const selectedPageIndex = () => - container?.querySelector('[data-testid="selected-page-index"]') - ?.textContent - const subscribedPageSize = () => - container?.querySelector('[data-testid="subscribed-page-size"]') - ?.textContent + const selectedPageIndex = () => text('Selected page index') + const subscribedPageSize = () => text('Subscribed page size') expect(harnessRenderCount).toBe(1) expect(selectedPageIndex()).toBe('0') expect(subscribedPageSize()).toBe('10') - React.act(() => { - container - ?.querySelector('[data-action="resize-page"]') - ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + act(() => { + click('Resize page') }) expect(harnessRenderCount).toBe(1) expect(selectedPageIndex()).toBe('0') expect(subscribedPageSize()).toBe('20') - React.act(() => { - container - ?.querySelector('[data-action="next-page"]') - ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + act(() => { + click('Next page') }) expect(harnessRenderCount).toBe(2) @@ -881,64 +792,41 @@ describe('useTable state subscriptions', () => { return ( <> - {tick} - + {tick} + {table.state.pagination.pageIndex} - + {table.atoms.pagination.get().pageIndex} - - + ) } - const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const consoleError = vi.spyOn(console, 'error') render() expect(harnessRenderCount).toBe(1) - React.act(() => { - container - ?.querySelector('[data-action="unrelated-update"]') - ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + act(() => { + click('Unrelated update') }) - expect( - container?.querySelector('[data-testid="unrelated-tick"]')?.textContent, - ).toBe('1') - expect( - container?.querySelector('[data-testid="recreated-page-index"]') - ?.textContent, - ).toBe('0') + expect(text('Unrelated tick')).toBe('1') + expect(text('Recreated page index')).toBe('0') expect(harnessRenderCount).toBe(2) - React.act(() => { - container - ?.querySelector('[data-action="controlled-next-page"]') - ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + act(() => { + click('Next page') }) - expect( - container?.querySelector('[data-testid="recreated-page-index"]') - ?.textContent, - ).toBe('1') - expect( - container?.querySelector('[data-testid="recreated-atom-page-index"]') - ?.textContent, - ).toBe('1') + expect(text('Recreated page index')).toBe('1') + expect(text('Recreated atom page index')).toBe('1') expect(harnessRenderCount).toBe(3) const errors = consoleError.mock.calls.flat().map(String).join('\n') @@ -961,8 +849,8 @@ describe('useTable state subscriptions', () => { return ( <> - {table.state.pageIndex} - + {table.state.pageIndex} + {JSON.stringify(table.state)} @@ -970,30 +858,22 @@ describe('useTable state subscriptions', () => { ) } - const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const consoleError = vi.spyOn(console, 'error') render() - const pageIndex = () => - container?.querySelector('[data-testid="page-index"]')?.textContent - const selectedState = () => - container?.querySelector('[data-testid="selected-state"]')?.textContent - const nextPage = () => - container?.querySelector('button')?.dispatchEvent( - new MouseEvent('click', { - bubbles: true, - }), - ) + const pageIndex = () => text('Page index') + const selectedState = () => text('Selected state') expect(selectedState()).toBe('{"pageIndex":0}') expect(pageIndex()).toBe('0') - React.act(() => { - nextPage() + act(() => { + click('Next page') }) expect(pageIndex()).toBe('1') - React.act(() => { - nextPage() + act(() => { + click('Next page') }) expect(pageIndex()).toBe('2') diff --git a/packages/react-table/vite.config.ts b/packages/react-table/vite.config.ts index 92d9339465..5e0a28ed74 100644 --- a/packages/react-table/vite.config.ts +++ b/packages/react-table/vite.config.ts @@ -8,7 +8,6 @@ export default defineConfig({ name: packageJson.name, dir: './tests', watch: false, - environment: 'jsdom', globals: true, setupFiles: ['./tests/test-setup.ts'], }, diff --git a/packages/solid-table/package.json b/packages/solid-table/package.json index edd9324d48..fc78aa22be 100644 --- a/packages/solid-table/package.json +++ b/packages/solid-table/package.json @@ -54,6 +54,8 @@ "scripts": { "clean": "rimraf ./build && rimraf ./dist", "test:eslint": "eslint ./src", + "test:lib": "vitest --passWithNoTests", + "test:lib:dev": "pnpm test:lib --watch", "test:types": "tsc", "test:build": "publint --strict", "build": "tsdown" @@ -63,6 +65,7 @@ "@tanstack/table-core": "workspace:*" }, "devDependencies": { + "@solidjs/testing-library": "^0.8.10", "solid-js": "^1.9.13", "vite-plugin-solid": "^2.11.12" }, diff --git a/packages/solid-table/src/FlexRender.tsx b/packages/solid-table/src/FlexRender.tsx index f41b005b05..7109255129 100644 --- a/packages/solid-table/src/FlexRender.tsx +++ b/packages/solid-table/src/FlexRender.tsx @@ -1,4 +1,4 @@ -import { Match, Switch, createComponent } from 'solid-js' +import { Match, Show, Switch, createComponent } from 'solid-js' import type { JSX } from 'solid-js' import type { Cell, @@ -24,7 +24,7 @@ export function flexRender( Comp: ((_props: TProps) => JSX.Element) | JSX.Element | undefined, props: TProps, ): JSX.Element { - if (!Comp) return null + if (Comp === null || Comp === undefined) return null if (typeof Comp === 'function') { return createComponent(Comp, props as any) @@ -105,16 +105,22 @@ export function FlexRender< const groupingDef = def as typeof def & { aggregatedCell?: typeof def.cell } - if (groupingCell.getIsAggregated?.()) { - return flexRender( - groupingDef.aggregatedCell ?? def.cell, - c.getContext(), - ) - } - if (groupingCell.getIsPlaceholder?.()) { - return null - } - return flexRender(def.cell, c.getContext()) + + return ( + + {flexRender(def.cell, c.getContext())} + + } + > + {flexRender( + groupingDef.aggregatedCell ?? def.cell, + c.getContext(), + )} + + ) }} diff --git a/packages/solid-table/tests/unit/adapterReactivity.test.ts b/packages/solid-table/tests/unit/adapterReactivity.test.ts new file mode 100644 index 0000000000..a07f574d7b --- /dev/null +++ b/packages/solid-table/tests/unit/adapterReactivity.test.ts @@ -0,0 +1,303 @@ +import { describe, expect, test, vi } from 'vitest' +import { + batch, + createEffect, + createMemo, + createRoot, + createSignal, +} from 'solid-js' +import { createAtom } from '@tanstack/store' +import { stockFeatures } from '@tanstack/table-core' +import { createTable } from '../../src/createTable' +import type { ColumnDef, RowSelectionState } from '@tanstack/table-core' + +describe('Solid adapter lifecycle and option ownership', () => { + type Data = { id: string; title: string } + const idColumn: ColumnDef = { + id: 'id', + accessorKey: 'id', + } + const titleColumn: ColumnDef = { + id: 'title', + accessorKey: 'title', + } + + function createEffectTestRoot(setup: () => T) { + let dispose!: () => void + const value = createRoot((rootDispose) => { + dispose = rootDispose + return setup() + }) + return { dispose, value } + } + + test('disposing the owner unsubscribes external atoms and stops reactions', () => { + const sourceAtom = createAtom({}) + const subscribeSpy = vi.spyOn(sourceAtom, 'subscribe') + + const { dispose, value } = createEffectTestRoot(() => { + const table = createTable({ + data: [{ id: '1', title: 'Title' }], + columns: [idColumn, titleColumn], + features: stockFeatures, + getRowId: (row) => row.id, + atoms: { + rowSelection: sourceAtom, + }, + }) + const stateCaptor = vi.fn<(state: RowSelectionState) => void>() + + createEffect(() => stateCaptor(table.atoms.rowSelection.get())) + + return { stateCaptor, table } + }) + const { stateCaptor, table } = value + + expect(subscribeSpy).toHaveBeenCalledTimes(1) + + const subscription = subscribeSpy.mock.results[0]!.value + const unsubscribeSpy = vi.spyOn(subscription, 'unsubscribe') + + sourceAtom.set({ 1: true }) + expect(stateCaptor.mock.calls).toEqual([[{}], [{ 1: true }]]) + + dispose() + + expect(unsubscribeSpy).toHaveBeenCalledTimes(1) + + sourceAtom.set({ 2: true }) + + expect(sourceAtom.get()).toEqual({ 2: true }) + expect(table.atoms.rowSelection.get()).toEqual({ 1: true }) + expect(stateCaptor.mock.calls).toEqual([[{}], [{ 1: true }]]) + + table.setRowSelection({ 3: true }) + + expect(sourceAtom.get()).toEqual({ 2: true }) + }) + + test('controlled state can release and reacquire ownership without losing the latest value', () => { + const { dispose, value } = createEffectTestRoot(() => { + const [controlledState, setControlledState] = createSignal<{ + rowSelection?: RowSelectionState + }>({ rowSelection: { 1: true } }) + const table = createTable({ + data: [ + { id: '1', title: 'One' }, + { id: '2', title: 'Two' }, + ], + columns: [idColumn, titleColumn], + features: stockFeatures, + getRowId: (row) => row.id, + get state() { + return controlledState() + }, + }) + const stateCaptor = vi.fn<(state: RowSelectionState) => void>() + + createEffect(() => stateCaptor(table.atoms.rowSelection.get())) + + return { setControlledState, stateCaptor, table } + }) + const { setControlledState, stateCaptor, table } = value + + try { + expect(table.atoms.rowSelection.get()).toEqual({ 1: true }) + + setControlledState({}) + expect(table.options.state).toEqual({}) + expect(table.atoms.rowSelection.get()).toEqual({ 1: true }) + + table.setRowSelection({ 2: true }) + expect(table.atoms.rowSelection.get()).toEqual({ 2: true }) + + setControlledState({ rowSelection: { 1: true, 2: true } }) + expect(table.atoms.rowSelection.get()).toEqual({ + 1: true, + 2: true, + }) + + table.setRowSelection({}) + expect(table.atoms.rowSelection.get()).toEqual({ + 1: true, + 2: true, + }) + + setControlledState({}) + expect(table.atoms.rowSelection.get()).toEqual({}) + expect(stateCaptor.mock.calls).toEqual([ + [{ 1: true }], + [{ 2: true }], + [{ 1: true, 2: true }], + [{}], + ]) + } finally { + dispose() + } + }) + + test('an external atom takes precedence over controlled state and receives table writes', () => { + const externalAtom = createAtom({ 2: true }) + const { dispose, value } = createEffectTestRoot(() => { + const [controlledSelection, setControlledSelection] = + createSignal({ 1: true }) + const table = createTable({ + data: [ + { id: '1', title: 'One' }, + { id: '2', title: 'Two' }, + ], + columns: [idColumn, titleColumn], + features: stockFeatures, + getRowId: (row) => row.id, + state: { + get rowSelection() { + return controlledSelection() + }, + }, + atoms: { + rowSelection: externalAtom, + }, + }) + const stateCaptor = vi.fn<(state: RowSelectionState) => void>() + const isSelectedCaptor = vi.fn<(selected: boolean) => void>() + const isSelected = createMemo(() => table.getRow('1').getIsSelected()) + + createEffect(() => stateCaptor(table.atoms.rowSelection.get())) + createEffect(() => isSelectedCaptor(isSelected())) + + return { isSelectedCaptor, setControlledSelection, stateCaptor, table } + }) + const { isSelectedCaptor, setControlledSelection, stateCaptor, table } = + value + + try { + expect(table.atoms.rowSelection.get()).toEqual({ 2: true }) + + setControlledSelection({ 1: true, 2: true }) + expect(table.atoms.rowSelection.get()).toEqual({ 2: true }) + + externalAtom.set({ 1: true }) + expect(table.atoms.rowSelection.get()).toEqual({ 1: true }) + + table.setRowSelection({ 2: true }) + expect(externalAtom.get()).toEqual({ 2: true }) + expect(table.atoms.rowSelection.get()).toEqual({ 2: true }) + expect(stateCaptor.mock.calls).toEqual([ + [{ 2: true }], + [{ 1: true }], + [{ 2: true }], + ]) + expect(isSelectedCaptor.mock.calls).toEqual([[false], [true], [false]]) + } finally { + dispose() + } + }) + + test('rapid batched option updates publish only the final data, columns, and option values', () => { + const { dispose, value } = createEffectTestRoot(() => { + const [data, setData] = createSignal>([ + { id: '1', title: 'Initial' }, + ]) + const [columns, setColumns] = createSignal< + Array> + >([idColumn]) + const [enableRowSelection, setEnableRowSelection] = createSignal(true) + const table = createTable({ + features: stockFeatures, + get data() { + return data() + }, + get columns() { + return columns() + }, + get enableRowSelection() { + return enableRowSelection() + }, + getRowId: (row) => row.id, + }) + const snapshotCaptor = + vi.fn< + (snapshot: { + canSelect: boolean + columnIds: Array + values: Array + }) => void + >() + + createEffect(() => { + const row = table.getRowModel().rows[0]! + snapshotCaptor({ + canSelect: row.getCanSelect(), + columnIds: table.getAllLeafColumns().map((column) => column.id), + values: row.getAllCells().map((cell) => cell.getValue()), + }) + }) + + return { + setColumns, + setData, + setEnableRowSelection, + snapshotCaptor, + } + }) + const { setColumns, setData, setEnableRowSelection, snapshotCaptor } = value + + try { + batch(() => { + setData([{ id: '2', title: 'Intermediate' }]) + setColumns([idColumn, titleColumn]) + setEnableRowSelection(false) + setData([{ id: '3', title: 'Final' }]) + setColumns([titleColumn]) + }) + + expect(snapshotCaptor.mock.calls).toEqual([ + [ + { + canSelect: true, + columnIds: ['id'], + values: ['1'], + }, + ], + [ + { + canSelect: false, + columnIds: ['title'], + values: ['Final'], + }, + ], + ]) + } finally { + dispose() + } + }) + + test('table APIs use the latest signal-backed option callback', () => { + createRoot((dispose) => { + const firstHandler = vi.fn() + const secondHandler = vi.fn() + const [onRowSelectionChange, setOnRowSelectionChange] = + createSignal(firstHandler) + const table = createTable({ + data: [{ id: '1', title: 'Title' }], + columns: [idColumn, titleColumn], + features: stockFeatures, + getRowId: (row) => row.id, + get onRowSelectionChange() { + return onRowSelectionChange() + }, + }) + + table.toggleAllRowsSelected(true) + expect(firstHandler).toHaveBeenCalledTimes(1) + expect(secondHandler).not.toHaveBeenCalled() + + setOnRowSelectionChange(() => secondHandler) + table.toggleAllRowsSelected(false) + + expect(firstHandler).toHaveBeenCalledTimes(1) + expect(secondHandler).toHaveBeenCalledTimes(1) + dispose() + }) + }) +}) diff --git a/packages/solid-table/tests/unit/createTable.test.ts b/packages/solid-table/tests/unit/createTable.test.ts new file mode 100644 index 0000000000..9459fd3c02 --- /dev/null +++ b/packages/solid-table/tests/unit/createTable.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test, vi } from 'vitest' +import { createEffect, createRoot, createSignal } from 'solid-js' +import { createPaginatedRowModel, stockFeatures } from '@tanstack/table-core' +import { createTable } from '../../src/createTable' +import type { ColumnDef, PaginationState } from '@tanstack/table-core' + +describe('createTable', () => { + type Data = { id: string; title: string } + const columns: Array> = [ + { + id: 'id', + header: 'Id', + accessorKey: 'id', + cell: (context) => context.getValue(), + }, + { + id: 'title', + header: 'Title', + accessorKey: 'title', + cell: (context) => context.getValue(), + }, + ] + const paginatedFeatures = { + ...stockFeatures, + paginatedRowModel: createPaginatedRowModel(), + } + const paginatedColumns = columns as Array< + ColumnDef + > + + test('row models react to controlled pagination changes', () => { + let dispose!: () => void + let setPageSize!: (size: number) => void + const rowIdsCaptor = vi.fn<(ids: Array) => void>() + + createRoot((rootDispose) => { + dispose = rootDispose + const [pagination, setPaginationSignal] = createSignal({ + pageSize: 5, + pageIndex: 0, + }) + const data = Array.from({ length: 10 }, (_, index) => ({ + id: String(index), + title: `Title ${index}`, + })) + const table = createTable({ + data, + columns: paginatedColumns, + features: paginatedFeatures, + getRowId: (row) => row.id, + state: { + get pagination() { + return pagination() + }, + }, + onPaginationChange: setPaginationSignal, + }) + + setPageSize = table.setPageSize + createEffect(() => + rowIdsCaptor(table.getRowModel().rows.map((row) => row.id)), + ) + }) + + try { + expect(rowIdsCaptor.mock.calls).toEqual([[['0', '1', '2', '3', '4']]]) + + setPageSize(3) + + expect(rowIdsCaptor.mock.calls).toEqual([ + [['0', '1', '2', '3', '4']], + [['0', '1', '2']], + ]) + } finally { + dispose() + } + }) +}) diff --git a/packages/solid-table/tests/unit/flexRender.test.ts b/packages/solid-table/tests/unit/flexRender.test.ts new file mode 100644 index 0000000000..2864679ae8 --- /dev/null +++ b/packages/solid-table/tests/unit/flexRender.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test, vi } from 'vitest' +import { createRoot } from 'solid-js' +import { flexRender } from '../../src/FlexRender' + +describe('flexRender', () => { + test('handles empty, static, and component templates', () => { + createRoot((dispose) => { + expect(flexRender(undefined, { value: 'unused' })).toBeNull() + expect(flexRender('static', { value: 'unused' })).toBe('static') + expect(flexRender(0, { value: 'unused' })).toBe(0) + + const Component = vi.fn((props: { value: string }) => { + return `component:${props.value}` + }) + + expect(flexRender(Component, { value: 'rendered' })).toBe( + 'component:rendered', + ) + expect(Component).toHaveBeenCalledWith({ value: 'rendered' }) + dispose() + }) + }) +}) diff --git a/packages/solid-table/tests/unit/reactivity.test.ts b/packages/solid-table/tests/unit/reactivity.test.ts index a8b8f9eb8c..a1a3233707 100644 --- a/packages/solid-table/tests/unit/reactivity.test.ts +++ b/packages/solid-table/tests/unit/reactivity.test.ts @@ -1,10 +1,17 @@ -import { describe, expect, test } from 'vitest' -import { createRoot, getOwner } from 'solid-js' +import { describe, expect, test, vi } from 'vitest' +import { + createEffect, + createMemo, + createRoot, + createSignal, + getOwner, + onCleanup, +} from 'solid-js' import { createAtom } from '@tanstack/store' import { stockFeatures } from '@tanstack/table-core' import { createTable } from '../../src/createTable' import { solidReactivity } from '../../src/reactivity' -import type { ColumnDef } from '@tanstack/table-core' +import type { ColumnDef, RowSelectionState } from '@tanstack/table-core' describe('solidReactivity', () => { test('readonly atoms update when wrapped external TanStack Store atoms update', () => { @@ -58,34 +65,245 @@ describe('solidReactivity', () => { }) }) -describe('table.Subscribe', () => { - type Data = { id: string } +describe('Solid table reactivity integration', () => { + type Data = { id: string; title: string } + const initialData: Array = [{ id: '1', title: 'Title' }] const columns: Array> = [ { id: 'id', + header: 'Id', accessorKey: 'id', + cell: (context) => context.getValue(), + }, + { + id: 'title', + header: 'Title', + accessorKey: 'title', + cell: (context) => context.getValue(), }, ] - test('passes table atoms to children', () => { - createRoot((dispose) => { + function createTestTable(data: () => Array = () => initialData) { + return createTable({ + features: { ...stockFeatures }, + columns, + get data() { + return data() + }, + getRowId: (row) => row.id, + }) + } + + function createEffectTestRoot(setup: () => T) { + let dispose!: () => void + const value = createRoot((rootDispose) => { + dispose = rootDispose + return setup() + }) + return { dispose, value } + } + + test('effects respond to the table inputs they read', () => { + const { dispose, value } = createEffectTestRoot(() => { + const [data, setData] = createSignal>(initialData) + const table = createTestTable(data) + const captors = { + isSelectedRow1: vi.fn<(value: boolean) => void>(), + titleValue: vi.fn<(value: unknown) => void>(), + columnIsVisible: vi.fn<(value: boolean) => void>(), + } + const row = createMemo(() => table.getRowModel().rows[0]!) + const titleCell = createMemo(() => row().getAllCells()[1]!) + + createEffect(() => captors.isSelectedRow1(row().getIsSelected())) + createEffect(() => captors.titleValue(titleCell().getValue())) + createEffect(() => + captors.columnIsVisible(table.getColumn('id')!.getIsVisible()), + ) + + return { captors, setData, table } + }) + const { captors, setData, table } = value + + try { + expect(captors.isSelectedRow1.mock.calls).toEqual([[false]]) + expect(captors.titleValue.mock.calls).toEqual([['Title']]) + expect(captors.columnIsVisible.mock.calls).toEqual([[true]]) + + Object.values(captors).forEach((captor) => captor.mockClear()) + table.getRow('1').toggleSelected(true) + + expect(captors.isSelectedRow1.mock.calls).toEqual([[true]]) + expect(captors.titleValue).not.toHaveBeenCalled() + expect(captors.columnIsVisible).not.toHaveBeenCalled() + + Object.values(captors).forEach((captor) => captor.mockClear()) + setData([{ id: '1', title: 'Title 3' }]) + + expect(captors.titleValue.mock.lastCall).toEqual(['Title 3']) + + Object.values(captors).forEach((captor) => captor.mockClear()) + table.getColumn('id')!.toggleVisibility(false) + + expect(captors.columnIsVisible.mock.calls).toEqual([[false]]) + expect(captors.isSelectedRow1).not.toHaveBeenCalled() + expect(captors.titleValue).not.toHaveBeenCalled() + } finally { + dispose() + } + }) + + test('table store can be subscribed from another reactive effect', () => { + const { dispose, value } = createEffectTestRoot(() => { + const table = createTestTable() + const tableStateCaptor = + vi.fn<(state: ReturnType) => void>() + + createEffect(() => { + const subscription = table.store.subscribe(() => { + tableStateCaptor(table.store.get()) + }) + onCleanup(() => subscription.unsubscribe()) + }) + + return { table, tableStateCaptor } + }) + const { table, tableStateCaptor } = value + + try { + table.toggleAllRowsSelected(true) + + expect(tableStateCaptor).toHaveBeenCalledTimes(2) + expect( + tableStateCaptor.mock.calls.map(([state]) => state.rowSelection), + ).toEqual([{}, { 1: true }]) + } finally { + dispose() + } + }) + + test('table state reacts to every external signal state update', () => { + const { dispose, value } = createEffectTestRoot(() => { + const [rowSelection, setRowSelection] = createSignal( + {}, + ) const table = createTable({ - data: [{ id: '1' }], + data: initialData, + features: { ...stockFeatures }, columns, - _features: stockFeatures, - _rowModels: {}, + getRowId: (row) => row.id, + state: { + get rowSelection() { + return rowSelection() + }, + }, + }) + const tableStateCaptor = vi.fn<(value: RowSelectionState) => void>() + + createEffect(() => { + tableStateCaptor(table.atoms.rowSelection.get()) }) - let received: unknown - table.Subscribe({ - children: (atoms) => { - received = atoms - return null + return { setRowSelection, tableStateCaptor } + }) + const { setRowSelection, tableStateCaptor } = value + + try { + setRowSelection({ 1: true }) + setRowSelection({ 1: true, 2: true }) + setRowSelection({ 2: true }) + + expect(tableStateCaptor.mock.calls).toEqual([ + [{}], + [{ 1: true }], + [{ 1: true, 2: true }], + [{ 2: true }], + ]) + } finally { + dispose() + } + }) + + test('table state reacts to internal table state updates', () => { + const { dispose, value } = createEffectTestRoot(() => { + const table = createTable({ + data: initialData, + features: { ...stockFeatures }, + columns, + getRowId: (row) => row.id, + initialState: { + pagination: { + pageIndex: 0, + pageSize: 20, + }, }, }) + const pageSizeCaptor = vi.fn<(value: number) => void>() + const storePageSizeCaptor = vi.fn<(value: number) => void>() + + createEffect(() => { + pageSizeCaptor(table.atoms.pagination.get().pageSize) + }) + createEffect(() => { + storePageSizeCaptor(table.store.get().pagination.pageSize) + }) + + return { pageSizeCaptor, storePageSizeCaptor, table } + }) + const { pageSizeCaptor, storePageSizeCaptor, table } = value - expect(received).toBe(table.atoms) + try { + expect(pageSizeCaptor.mock.calls).toEqual([[20]]) + expect(storePageSizeCaptor.mock.calls).toEqual([[20]]) + + table.setPageSize(50) + table.setPageSize(100) + + expect(pageSizeCaptor.mock.calls).toEqual([[20], [50], [100]]) + expect(storePageSizeCaptor.mock.calls).toEqual([[20], [50], [100]]) + } finally { dispose() + } + }) + + test('table state property reads only track the accessed slice', () => { + const { dispose, value } = createEffectTestRoot(() => { + const table = createTable({ + data: initialData, + features: { ...stockFeatures }, + columns, + getRowId: (row) => row.id, + initialState: { + pagination: { + pageIndex: 0, + pageSize: 20, + }, + }, + }) + const pageSizeCaptor = vi.fn<(value: number) => void>() + const stateJsonCaptor = vi.fn<(value: string) => void>() + + createEffect(() => { + pageSizeCaptor(table.atoms.pagination.get().pageSize) + }) + createEffect(() => { + stateJsonCaptor(JSON.stringify(table.store.get(), null, 2)) + }) + + return { pageSizeCaptor, stateJsonCaptor, table } }) + const { pageSizeCaptor, stateJsonCaptor, table } = value + + try { + table.toggleAllRowsSelected(true) + + expect(pageSizeCaptor.mock.calls).toEqual([[20]]) + expect(stateJsonCaptor).toHaveBeenCalledTimes(2) + expect( + JSON.parse(stateJsonCaptor.mock.calls.at(-1)![0]).rowSelection, + ).toEqual({ 1: true }) + } finally { + dispose() + } }) }) diff --git a/packages/solid-table/tests/unit/rendering.test.tsx b/packages/solid-table/tests/unit/rendering.test.tsx new file mode 100644 index 0000000000..124a1844fa --- /dev/null +++ b/packages/solid-table/tests/unit/rendering.test.tsx @@ -0,0 +1,459 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, test, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library' +import { createSignal } from 'solid-js' +import { stockFeatures } from '@tanstack/table-core' +import { FlexRender } from '../../src/FlexRender' +import { createTable } from '../../src/createTable' +import { createTableHook } from '../../src/createTableHook' +import type { ColumnDef } from '@tanstack/table-core' + +afterEach(() => cleanup()) + +describe('FlexRender', () => { + type Data = { id: string; name: string } + type CellMode = 'aggregate' | 'normal' | 'placeholder' + const columns: Array> = [ + { + id: 'name', + accessorKey: 'name', + header: ({ column }) => `header:${column.id}`, + cell: ({ getValue }) => `cell:${getValue()}`, + aggregatedCell: ({ getValue }) => `aggregate:${getValue()}`, + footer: ({ column }) => `footer:${column.id}`, + }, + ] + + function CellHarness(props: { mode: CellMode }) { + const table = createTable({ + data: [{ id: '1', name: 'Ada' }], + columns, + features: stockFeatures, + getRowId: (row) => row.id, + }) + const cell = table.getRowModel().rows[0]!.getAllCells()[0]! + + if (props.mode === 'aggregate') { + vi.spyOn(cell, 'getIsAggregated').mockReturnValue(true) + } + + if (props.mode === 'placeholder') { + vi.spyOn(cell, 'getIsAggregated').mockReturnValue(false) + vi.spyOn(cell, 'getIsPlaceholder').mockReturnValue(true) + } + + return ( + + + + ) + } + + function HeaderFooterHarness() { + const table = createTable({ + data: [{ id: '1', name: 'Ada' }], + columns, + features: stockFeatures, + getRowId: (row) => row.id, + }) + const header = table.getHeaderGroups()[0]!.headers[0]! + const footer = table.getFooterGroups()[0]!.headers[0]! + + return ( + <> + + + + + + + + ) + } + + test('renders cell, aggregate, placeholder, header, and footer templates', () => { + render(() => ( + <> + + + + + + )) + + expect( + screen.getByRole('status', { name: 'normal cell' }).textContent, + ).toBe('cell:Ada') + expect( + screen.getByRole('status', { name: 'aggregate cell' }).textContent, + ).toBe('aggregate:Ada') + expect( + screen.getByRole('status', { name: 'placeholder cell' }).textContent, + ).toBe('') + expect(screen.getByRole('status', { name: 'header' }).textContent).toBe( + 'header:name', + ) + expect(screen.getByRole('status', { name: 'footer' }).textContent).toBe( + 'footer:name', + ) + }) + + test('reacts when a cell changes grouping mode', () => { + function GroupingCellHarness() { + const [mode, setMode] = createSignal('normal') + const table = createTable({ + data: [{ id: '1', name: 'Ada' }], + columns, + features: stockFeatures, + getRowId: (row) => row.id, + }) + const cell = table.getRowModel().rows[0]!.getAllCells()[0]! + + vi.spyOn(cell, 'getIsAggregated').mockImplementation( + () => mode() === 'aggregate', + ) + vi.spyOn(cell, 'getIsPlaceholder').mockImplementation( + () => mode() === 'placeholder', + ) + + return ( + <> + + + + + + + + ) + } + + render(() => ) + + const renderedCell = () => + screen.getByRole('status', { name: 'grouping cell' }).textContent + + expect(renderedCell()).toBe('cell:Ada') + + fireEvent.click(screen.getByRole('button', { name: 'Show aggregate' })) + expect(renderedCell()).toBe('aggregate:Ada') + + fireEvent.click(screen.getByRole('button', { name: 'Show placeholder' })) + expect(renderedCell()).toBe('') + + fireEvent.click(screen.getByRole('button', { name: 'Show normal' })) + expect(renderedCell()).toBe('cell:Ada') + }) + + test('updates when a truthy cell prop is replaced with a new instance', () => { + function ReactiveCellHarness() { + const [data, setData] = createSignal>([ + { id: '1', name: 'Ada' }, + ]) + const table = createTable({ + get data() { + return data() + }, + columns, + features: stockFeatures, + getRowId: (row) => row.id, + }) + + return ( + <> + + + + + + ) + } + + render(() => ) + + expect( + screen.getByRole('status', { name: 'rendered cell' }).textContent, + ).toBe('cell:Ada') + + fireEvent.click(screen.getByRole('button', { name: 'Replace cell' })) + + expect( + screen.getByRole('status', { name: 'rendered cell' }).textContent, + ).toBe('cell:Grace') + }) +}) + +describe('table.Subscribe', () => { + test('updates mounted content for the atom read by its child', () => { + function SubscribeHarness() { + const table = createTable({ + data: [{ id: '1' }], + columns: [{ id: 'id', accessorKey: 'id' }], + features: stockFeatures, + getRowId: (row) => row.id, + }) + + return ( + <> + + + {(atoms) => ( + {String(Boolean(atoms.rowSelection.get()['1']))} + )} + + + + + ) + } + + render(() => ) + + expect( + screen.getByRole('status', { name: 'subscribed selection' }).textContent, + ).toBe('false') + + fireEvent.click( + screen.getByRole('button', { name: 'Select subscribed row' }), + ) + + expect( + screen.getByRole('status', { name: 'subscribed selection' }).textContent, + ).toBe('true') + }) +}) + +describe('createTableHook runtime', () => { + type Data = { id: string; name: string } + const TableBadge = () => table-badge + const CellBadge = () => cell-badge + const HeaderBadge = () => header-badge + + function createTestHook() { + return createTableHook({ + features: stockFeatures, + enableRowSelection: false, + getRowId: (row) => row.id, + tableComponents: { TableBadge }, + cellComponents: { CellBadge }, + headerComponents: { HeaderBadge }, + }) + } + + test('binds features and components while per-table options override defaults', () => { + const hook = createTestHook() + const columnHelper = hook.createAppColumnHelper() + const columns = columnHelper.columns([ + columnHelper.accessor('name', { + header: 'Name', + cell: ({ getValue }) => getValue(), + }), + ]) + let tableRef: ReturnType> | undefined + + function Harness() { + const table = hook.createAppTable({ + data: [{ id: '1', name: 'Ada' }], + columns, + enableRowSelection: true, + }) + tableRef = table + + return ( + + + {String(table.getRow('1').getCanSelect())} + + + + ) + } + + render(() => ) + + expect( + screen.getByRole('status', { name: 'row can be selected' }).textContent, + ).toBe('true') + expect(screen.getByText('table-badge').textContent).toBe('table-badge') + expect(hook.appFeatures).toBe(stockFeatures) + expect(tableRef?.TableBadge).toBe(TableBadge) + expect(tableRef?.FlexRender).toBe(FlexRender) + expect(tableRef?.AppTable).toEqual(expect.any(Function)) + expect(tableRef?.AppCell).toEqual(expect.any(Function)) + expect(tableRef?.AppHeader).toEqual(expect.any(Function)) + expect(tableRef?.AppFooter).toEqual(expect.any(Function)) + }) + + test('provides table, cell, and header contexts with bound render helpers', () => { + const hook = createTestHook() + const columnHelper = hook.createAppColumnHelper() + const columns = columnHelper.columns([ + columnHelper.accessor('name', { + header: ({ column }) => `header:${column.id}`, + cell: ({ getValue }) => `cell:${getValue()}`, + footer: ({ column }) => `footer:${column.id}`, + }), + ]) + let tableFromContext: unknown + let cellFromContext: unknown + let headerFromContext: unknown + let footerFromContext: unknown + let tableRef: ReturnType> | undefined + + function Harness() { + const table = hook.createAppTable({ + data: [{ id: '1', name: 'Ada' }], + columns, + }) + tableRef = table + const cell = table.getRow('1').getAllCells()[0]! + const header = table.getHeaderGroups()[0]!.headers[0]! + const footer = table.getFooterGroups()[0]!.headers[0]! + + function TableContextProbe() { + tableFromContext = hook.useTableContext() + return ( + + {String(tableFromContext === table)} + + ) + } + + return ( + + + + + {(value) => { + cellFromContext = hook.useCellContext() + return ( + <> + + {String(cellFromContext === cell)} + + + {String(value.CellBadge === CellBadge)} + + + + + + + ) + }} + + + {(value) => { + headerFromContext = hook.useHeaderContext() + return ( + <> + + {String(headerFromContext === header)} + + + {String(value.HeaderBadge === HeaderBadge)} + + + + + + + ) + }} + + + {(value) => { + footerFromContext = hook.useHeaderContext() + return ( + <> + + {String(footerFromContext === footer)} + + + + + + ) + }} + + + ) + } + + render(() => ) + + expect( + screen.getByRole('status', { name: 'table context matches' }).textContent, + ).toBe('true') + expect( + screen.getByRole('status', { name: 'cell context matches' }).textContent, + ).toBe('true') + expect( + screen.getByRole('status', { name: 'header context matches' }) + .textContent, + ).toBe('true') + expect( + screen.getByRole('status', { name: 'footer context matches' }) + .textContent, + ).toBe('true') + expect( + screen.getByRole('status', { name: 'cell component is bound' }) + .textContent, + ).toBe('true') + expect( + screen.getByRole('status', { name: 'header component is bound' }) + .textContent, + ).toBe('true') + expect(screen.getByText('table-badge').textContent).toBe('table-badge') + expect(screen.getByText('cell-badge').textContent).toBe('cell-badge') + expect(screen.getByText('header-badge').textContent).toBe('header-badge') + expect( + screen.getByRole('status', { name: 'rendered cell' }).textContent, + ).toBe('cell:Ada') + expect( + screen.getByRole('status', { name: 'rendered header' }).textContent, + ).toBe('header:name') + expect( + screen.getByRole('status', { name: 'rendered footer' }).textContent, + ).toBe('footer:name') + expect(tableFromContext).toBe(tableRef) + }) + + test('context hooks fail with actionable errors outside their providers', () => { + const hook = createTestHook() + + function TableContextFailure() { + hook.useTableContext() + return null + } + + function CellContextFailure() { + hook.useCellContext() + return null + } + + function HeaderContextFailure() { + hook.useHeaderContext() + return null + } + + expect(() => render(() => )).toThrow( + '`useTableContext` must be used within an `AppTable` component', + ) + cleanup() + expect(() => render(() => )).toThrow( + '`useCellContext` must be used within an `AppCell` component', + ) + cleanup() + expect(() => render(() => )).toThrow( + '`useHeaderContext` must be used within an `AppHeader` or `AppFooter` component', + ) + }) +}) diff --git a/packages/solid-table/tsconfig.json b/packages/solid-table/tsconfig.json index 5f7748754b..966bd728f5 100644 --- a/packages/solid-table/tsconfig.json +++ b/packages/solid-table/tsconfig.json @@ -4,5 +4,5 @@ "jsx": "preserve", "jsxImportSource": "solid-js" }, - "include": ["src", "eslint.config.js", "vite.config.ts"] + "include": ["src", "eslint.config.js", "vite.config.ts", "tests"] } diff --git a/packages/solid-table/vite.config.ts b/packages/solid-table/vite.config.ts index a872ffe359..cb974ecc22 100644 --- a/packages/solid-table/vite.config.ts +++ b/packages/solid-table/vite.config.ts @@ -3,4 +3,7 @@ import solid from 'vite-plugin-solid' export default defineConfig({ plugins: [solid()], + test: { + environment: 'node', + }, }) diff --git a/packages/vue-table/package.json b/packages/vue-table/package.json index 76a700b23b..fe7554bc27 100644 --- a/packages/vue-table/package.json +++ b/packages/vue-table/package.json @@ -41,6 +41,8 @@ "scripts": { "clean": "rimraf ./build && rimraf ./dist", "test:eslint": "eslint ./src", + "test:lib": "vitest --passWithNoTests", + "test:lib:dev": "pnpm test:lib --watch", "test:types": "tsc", "test:build": "publint --strict", "build": "tsdown" @@ -50,6 +52,7 @@ "@tanstack/table-core": "workspace:*" }, "devDependencies": { + "@testing-library/vue": "^8.1.0", "@vitejs/plugin-vue": "^6.0.7", "eslint-plugin-vue": "^10.9.2", "vue": "^3.5.38" diff --git a/packages/vue-table/src/FlexRender.ts b/packages/vue-table/src/FlexRender.ts index 14a657b950..b257a2dd4a 100644 --- a/packages/vue-table/src/FlexRender.ts +++ b/packages/vue-table/src/FlexRender.ts @@ -28,9 +28,17 @@ export interface FlexRenderHeader { * @example flexRender(cell.column.columnDef.cell, cell.getContext()) */ export function flexRender(render: any, props: any): any { + if (render === null || render === undefined) { + return render + } + if (typeof render === 'function') { const rendered = render(props) + if (rendered === null || rendered === undefined) { + return rendered + } + if (isVNode(rendered)) { return rendered } diff --git a/packages/vue-table/src/createTableHook.ts b/packages/vue-table/src/createTableHook.ts index 80e3bfcc59..479a4d0d32 100644 --- a/packages/vue-table/src/createTableHook.ts +++ b/packages/vue-table/src/createTableHook.ts @@ -313,22 +313,19 @@ export const AppFlexRender = defineComponent({ return () => { if (props.cell) { return h(FlexRender, { - render: props.cell.column.columnDef.cell, - props: props.cell.getContext(), + cell: props.cell, }) } if (props.header) { return h(FlexRender, { - render: props.header.column.columnDef.header, - props: props.header.getContext(), + header: props.header, }) } if (props.footer) { return h(FlexRender, { - render: props.footer.column.columnDef.footer, - props: props.footer.getContext(), + footer: props.footer, }) } diff --git a/packages/vue-table/tests/unit/adapter-lifecycle.test.ts b/packages/vue-table/tests/unit/adapter-lifecycle.test.ts new file mode 100644 index 0000000000..e3f35a8c54 --- /dev/null +++ b/packages/vue-table/tests/unit/adapter-lifecycle.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, test, vi } from 'vitest' +import { computed, effectScope, nextTick, ref, watchEffect } from 'vue' +import { createAtom } from '@tanstack/store' +import { stockFeatures } from '@tanstack/table-core' +import { useTable } from '../../src/useTable' +import type { + ColumnDef, + OnChangeFn, + RowSelectionState, +} from '@tanstack/table-core' + +describe('Vue adapter lifecycle and reactive options', () => { + type Data = { id: string; title: string } + + const columns: Array> = [ + { id: 'id', accessorKey: 'id' }, + { id: 'title', accessorKey: 'title' }, + ] + + test('scope disposal cleans up external atoms and stops later reactions', async () => { + const data = ref>([{ id: '1', title: 'First' }]) + const externalRowSelection = createAtom({}) + const subscribeSpy = vi.spyOn(externalRowSelection, 'subscribe') + + const rowIdsCaptor = vi.fn<(ids: Array) => void>() + const selectionCaptor = vi.fn<(state: RowSelectionState) => void>() + const scope = effectScope() + const table = scope.run(() => { + const table = useTable({ + data, + columns, + features: stockFeatures, + getRowId: (row) => row.id, + atoms: { + rowSelection: externalRowSelection, + }, + }) + + watchEffect( + () => rowIdsCaptor(table.getRowModel().rows.map((row) => row.id)), + { flush: 'sync' }, + ) + watchEffect(() => selectionCaptor(table.atoms.rowSelection.get()), { + flush: 'sync', + }) + + return table + })! + + data.value = [ + { id: '1', title: 'First' }, + { id: '2', title: 'Second' }, + ] + externalRowSelection.set({ 1: true }) + await nextTick() + + expect(rowIdsCaptor.mock.calls).toEqual([[['1']], [['1', '2']]]) + expect(selectionCaptor.mock.calls).toEqual([[{}], [{ 1: true }]]) + expect(subscribeSpy).toHaveBeenCalledTimes(1) + + const subscription = subscribeSpy.mock.results[0]!.value + const unsubscribeSpy = vi.spyOn(subscription, 'unsubscribe') + + scope.stop() + + expect(unsubscribeSpy).toHaveBeenCalledTimes(1) + + data.value = [{ id: '3', title: 'Third' }] + externalRowSelection.set({ 2: true }) + await nextTick() + + expect(rowIdsCaptor.mock.calls).toEqual([[['1']], [['1', '2']]]) + expect(selectionCaptor.mock.calls).toEqual([[{}], [{ 1: true }]]) + expect(table.getRowModel().rows.map((row) => row.id)).toEqual(['1', '2']) + expect(table.atoms.rowSelection.get()).toEqual({ 1: true }) + + table.toggleAllRowsSelected(true) + + expect(table.atoms.rowSelection.get()).toEqual({ 1: true, 2: true }) + expect(externalRowSelection.get()).toEqual({ 2: true }) + }) + + test('controlled state can release and regain ownership of a slice', async () => { + const state = ref<{ rowSelection?: RowSelectionState }>({ + rowSelection: { 1: true }, + }) + const scope = effectScope() + const table = scope.run(() => + useTable({ + data: [{ id: '1', title: 'First' }], + columns, + features: stockFeatures, + getRowId: (row) => row.id, + state, + }), + )! + + expect(table.atoms.rowSelection.get()).toEqual({ 1: true }) + + table.toggleAllRowsSelected(false) + expect(table.atoms.rowSelection.get()).toEqual({ 1: true }) + + state.value = {} + await nextTick() + expect(table.atoms.rowSelection.get()).toEqual({}) + + table.toggleAllRowsSelected(true) + expect(table.atoms.rowSelection.get()).toEqual({ 1: true }) + + state.value = { rowSelection: {} } + await nextTick() + expect(table.atoms.rowSelection.get()).toEqual({}) + + scope.stop() + }) + + test('external atoms take precedence over controlled state and receive table updates', async () => { + const state = ref<{ rowSelection: RowSelectionState }>({ + rowSelection: { 2: true }, + }) + const externalRowSelection = createAtom({ 1: true }) + const isSelectedCaptor = vi.fn<(selected: boolean) => void>() + const scope = effectScope() + const table = scope.run(() => { + const table = useTable({ + data: [ + { id: '1', title: 'First' }, + { id: '2', title: 'Second' }, + ], + columns, + features: stockFeatures, + getRowId: (row) => row.id, + state, + atoms: { + rowSelection: externalRowSelection, + }, + }) + + const isSelected = computed(() => table.getRow('1').getIsSelected()) + watchEffect(() => isSelectedCaptor(isSelected.value), { + flush: 'sync', + }) + + return table + })! + + expect(table.atoms.rowSelection.get()).toEqual({ 1: true }) + + state.value = { rowSelection: { 1: true, 2: true } } + await nextTick() + expect(table.atoms.rowSelection.get()).toEqual({ 1: true }) + + externalRowSelection.set({ 2: true }) + expect(table.atoms.rowSelection.get()).toEqual({ 2: true }) + + table.toggleAllRowsSelected(true) + expect(externalRowSelection.get()).toEqual({ 1: true, 2: true }) + expect(table.atoms.rowSelection.get()).toEqual({ 1: true, 2: true }) + expect(isSelectedCaptor.mock.calls).toEqual([[true], [false], [true]]) + + scope.stop() + }) + + test('rapid ref updates publish only the final option snapshot', async () => { + const data = ref>([{ id: '1', title: 'First' }]) + const rowIdsCaptor = vi.fn<(ids: Array) => void>() + const scope = effectScope() + + scope.run(() => { + const table = useTable({ + data, + columns, + features: stockFeatures, + getRowId: (row) => row.id, + }) + + watchEffect( + () => { + rowIdsCaptor(table.getRowModel().rows.map((row) => row.id)) + }, + { flush: 'sync' }, + ) + }) + + data.value = [{ id: '1', title: 'One' }] + data.value = [ + { id: '1', title: 'One' }, + { id: '2', title: 'Two' }, + ] + data.value = [ + { id: '1', title: 'One' }, + { id: '2', title: 'Two' }, + { id: '3', title: 'Three' }, + ] + await nextTick() + + expect(rowIdsCaptor.mock.calls).toEqual([[['1']], [['1', '2', '3']]]) + + scope.stop() + }) + + test('dynamic columns and option callbacks use their latest refs', async () => { + const reactiveColumns = ref>>([ + { id: 'id', accessorKey: 'id' }, + ]) + const firstSelectionHandler = vi.fn>() + const secondSelectionHandler = vi.fn>() + const onRowSelectionChange = ref>( + firstSelectionHandler, + ) + const scope = effectScope() + const table = scope.run(() => + useTable({ + data: [{ id: '1', title: 'First' }], + columns: reactiveColumns, + features: stockFeatures, + getRowId: (row) => row.id, + onRowSelectionChange, + }), + )! + + expect(table.getAllLeafColumns().map((column) => column.id)).toEqual(['id']) + + table.toggleAllRowsSelected(true) + expect(firstSelectionHandler).toHaveBeenCalledTimes(1) + expect(secondSelectionHandler).not.toHaveBeenCalled() + + reactiveColumns.value = [{ id: 'title', accessorKey: 'title' }] + onRowSelectionChange.value = secondSelectionHandler + await nextTick() + + expect(table.getAllLeafColumns().map((column) => column.id)).toEqual([ + 'title', + ]) + expect(table.getRowModel().rows[0]!.getValue('title')).toBe('First') + + table.toggleAllRowsSelected(true) + expect(firstSelectionHandler).toHaveBeenCalledTimes(1) + expect(secondSelectionHandler).toHaveBeenCalledTimes(1) + + scope.stop() + }) +}) diff --git a/packages/vue-table/tests/unit/flexRender.test.ts b/packages/vue-table/tests/unit/flexRender.test.ts new file mode 100644 index 0000000000..a7f87ba90f --- /dev/null +++ b/packages/vue-table/tests/unit/flexRender.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test, vi } from 'vitest' +import { defineComponent, h, isVNode } from 'vue' +import { flexRender } from '../../src/FlexRender' + +describe('flexRender', () => { + test('returns primitives, callbacks, VNodes, and component objects', () => { + const props = { value: 'Ada' } + const renderCallback = vi.fn( + (context: typeof props) => `Hello ${context.value}`, + ) + + expect(flexRender(null, props)).toBeNull() + expect(flexRender(undefined, props)).toBeUndefined() + expect(flexRender(0, props)).toBe(0) + expect(flexRender(() => null, props)).toBeNull() + expect(flexRender('Plain text', props)).toBe('Plain text') + expect(flexRender(renderCallback, props)).toBe('Hello Ada') + expect(renderCallback).toHaveBeenCalledWith(props) + + const existingVNode = h('strong', 'Existing') + expect(flexRender(() => existingVNode, props)).toBe(existingVNode) + + const NameComponent = defineComponent({ + props: { + value: { + type: String, + required: true, + }, + }, + setup(componentProps) { + return () => h('span', `Name: ${componentProps.value}`) + }, + }) + const componentVNode = flexRender(NameComponent, props) + + expect(isVNode(componentVNode)).toBe(true) + expect(componentVNode.type).toBe(NameComponent) + expect(componentVNode.props).toMatchObject(props) + + const returnedComponentVNode = flexRender(() => NameComponent, props) + + expect(isVNode(returnedComponentVNode)).toBe(true) + expect(returnedComponentVNode.type).toBe(NameComponent) + expect(returnedComponentVNode.props).toMatchObject(props) + }) +}) diff --git a/packages/vue-table/tests/unit/rendering.test.ts b/packages/vue-table/tests/unit/rendering.test.ts new file mode 100644 index 0000000000..d90866dd73 --- /dev/null +++ b/packages/vue-table/tests/unit/rendering.test.ts @@ -0,0 +1,411 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, test, vi } from 'vitest' +import { defineComponent, h, nextTick, ref } from 'vue' +import { cleanup, fireEvent, render, screen } from '@testing-library/vue' +import { stockFeatures } from '@tanstack/table-core' +import { FlexRender } from '../../src/FlexRender' +import { createTableHook } from '../../src/createTableHook' +import { useTable } from '../../src/useTable' + +afterEach(cleanup) + +function outputText(name: string) { + return screen.getByRole('status', { name }).textContent +} + +describe('FlexRender', () => { + test('supports cell modes, header/footer shorthand, and legacy props', () => { + const normalContext = { value: 'Normal' } + const aggregatedContext = { value: 'Aggregated' } + const headerContext = { label: 'Title' } + const footerContext = { label: 'Total' } + const normalCellRenderer = vi.fn( + (context: typeof normalContext) => `cell:${context.value}`, + ) + const aggregatedCellRenderer = vi.fn( + (context: typeof aggregatedContext) => `sum:${context.value}`, + ) + const headerRenderer = vi.fn( + (context: typeof headerContext) => `header:${context.label}`, + ) + const footerRenderer = vi.fn( + (context: typeof footerContext) => `footer:${context.label}`, + ) + const placeholderRenderer = vi.fn(() => 'should-not-render') + + const Root = defineComponent({ + setup() { + return () => + h('output', { 'aria-label': 'Flex render output' }, [ + h(FlexRender, { + cell: { + column: { + columnDef: { + cell: normalCellRenderer, + }, + }, + getContext: () => normalContext, + }, + }), + h(FlexRender, { + cell: { + column: { + columnDef: { + cell: normalCellRenderer, + aggregatedCell: aggregatedCellRenderer, + }, + }, + getContext: () => aggregatedContext, + getIsAggregated: () => true, + }, + }), + h(FlexRender, { + cell: { + column: { + columnDef: { + cell: placeholderRenderer, + }, + }, + getContext: () => normalContext, + getIsPlaceholder: () => true, + }, + }), + h(FlexRender, { + header: { + column: { + columnDef: { + header: headerRenderer, + }, + }, + getContext: () => headerContext, + }, + }), + h(FlexRender, { + footer: { + column: { + columnDef: { + footer: footerRenderer, + }, + }, + getContext: () => footerContext, + }, + }), + h(FlexRender, { + render: (context: { value: string }) => `legacy:${context.value}`, + props: { value: 'Legacy' }, + }), + ]) + }, + }) + + render(Root) + + expect(outputText('Flex render output')).toBe( + 'cell:Normalsum:Aggregatedheader:Titlefooter:Totallegacy:Legacy', + ) + expect(normalCellRenderer).toHaveBeenCalledOnce() + expect(normalCellRenderer).toHaveBeenCalledWith(normalContext) + expect(aggregatedCellRenderer).toHaveBeenCalledWith(aggregatedContext) + expect(placeholderRenderer).not.toHaveBeenCalled() + expect(headerRenderer).toHaveBeenCalledWith(headerContext) + expect(footerRenderer).toHaveBeenCalledWith(footerContext) + }) + + test('updates when the shorthand cell prop is replaced', async () => { + const cell = ref({ + column: { + columnDef: { + cell: (context: { value: string }) => `cell:${context.value}`, + }, + }, + getContext: () => ({ value: 'Ada' }), + }) + const Root = defineComponent({ + setup() { + return () => + h( + 'output', + { 'aria-label': 'Reactive flex render' }, + h(FlexRender, { cell: cell.value }), + ) + }, + }) + + render(Root) + + expect(outputText('Reactive flex render')).toBe('cell:Ada') + + cell.value = { + column: cell.value.column, + getContext: () => ({ value: 'Grace' }), + } + await nextTick() + + expect(outputText('Reactive flex render')).toBe('cell:Grace') + }) +}) + +describe('table.Subscribe', () => { + test('updates mounted content for the atom read by its child', async () => { + const Root = defineComponent({ + setup() { + const table = useTable({ + data: [{ id: '1' }], + columns: [{ id: 'id', accessorKey: 'id' }], + features: stockFeatures, + getRowId: (row) => row.id, + }) + + return () => + h('main', [ + table.Subscribe({ + children: (atoms) => + h( + 'output', + { 'aria-label': 'Subscribed row selection' }, + String(Boolean(atoms.rowSelection.get()['1'])), + ), + }), + h( + 'button', + { + onClick: () => table.getRow('1').toggleSelected(true), + }, + 'Select subscribed row', + ), + ]) + }, + }) + + render(Root) + + expect(outputText('Subscribed row selection')).toBe('false') + + await fireEvent.click( + screen.getByRole('button', { name: 'Select subscribed row' }), + ) + + expect(outputText('Subscribed row selection')).toBe('true') + }) +}) + +describe('createTableHook', () => { + type Data = { id: string; title: string } + + const TableBadge = defineComponent({ + setup() { + return () => h('span', 'table-component') + }, + }) + const CellBadge = defineComponent({ + setup() { + return () => h('span', 'cell-component') + }, + }) + const HeaderBadge = defineComponent({ + setup() { + return () => h('span', 'header-component') + }, + }) + + const hook = createTableHook({ + features: stockFeatures, + getRowId: (row: Data) => `row-${row.id}`, + tableComponents: { TableBadge }, + cellComponents: { CellBadge }, + headerComponents: { HeaderBadge }, + }) + const columnHelper = hook.createAppColumnHelper() + const columns = columnHelper.columns([ + columnHelper.accessor('title', { + header: (context) => `header:${context.column.id}`, + cell: (context) => `cell:${context.getValue()}`, + aggregatedCell: (context) => `aggregate:${context.getValue()}`, + footer: (context) => `footer:${context.column.id}`, + }), + ]) + + test('binds defaults, wrapper components, and all three contexts', () => { + const tableContextCaptor = vi.fn<(value: unknown) => void>() + const cellContextCaptor = vi.fn<(value: unknown) => void>() + const headerContextCaptor = vi.fn<(value: unknown) => void>() + const footerContextCaptor = vi.fn<(value: unknown) => void>() + let createdTable: unknown + let originalCell: unknown + let originalHeader: unknown + let originalFooter: unknown + let firstRowId: string | undefined + + const TableConsumer = defineComponent({ + setup() { + const table = hook.useTableContext() + tableContextCaptor(table) + + return () => h(table.TableBadge) + }, + }) + const CellConsumer = defineComponent({ + setup() { + const cell = hook.useCellContext() + cellContextCaptor(cell) + + return () => [h(cell.CellBadge), h(cell.FlexRender)] + }, + }) + const HeaderConsumer = defineComponent({ + setup() { + const header = hook.useHeaderContext() + headerContextCaptor(header) + + return () => [h(header.HeaderBadge), h(header.FlexRender)] + }, + }) + const FooterConsumer = defineComponent({ + setup() { + const header = hook.useHeaderContext() + footerContextCaptor(header) + + return () => h(header.FlexRender) + }, + }) + const Root = defineComponent({ + setup() { + const table = hook.useAppTable({ + data: [{ id: '1', title: 'First' }], + columns, + }) + const row = table.getRowModel().rows[0]! + const cell = row.getAllCells()[0]! + const header = table.getHeaderGroups()[0]!.headers[0]! + const footer = table.getFooterGroups()[0]!.headers[0]! + + createdTable = table + originalCell = cell + originalHeader = header + originalFooter = footer + firstRowId = row.id + + return () => + h( + 'main', + h(table.AppTable, null, { + default: () => [ + h(TableConsumer), + h( + table.AppCell, + { cell }, + { + default: () => h(CellConsumer), + }, + ), + h( + table.AppHeader, + { header }, + { + default: () => h(HeaderConsumer), + }, + ), + h( + table.AppFooter, + { header: footer }, + { + default: () => h(FooterConsumer), + }, + ), + ], + }), + ) + }, + }) + + render(Root) + + expect(hook.appFeatures).toBe(stockFeatures) + expect(firstRowId).toBe('row-1') + expect(tableContextCaptor).toHaveBeenCalledWith(createdTable) + expect(cellContextCaptor).toHaveBeenCalledWith(originalCell) + expect(headerContextCaptor).toHaveBeenCalledWith(originalHeader) + expect(footerContextCaptor).toHaveBeenCalledWith(originalFooter) + expect(screen.getByText('table-component').textContent).toBe( + 'table-component', + ) + expect(screen.getByText('cell-component').textContent).toBe( + 'cell-component', + ) + expect(screen.getByText('header-component').textContent).toBe( + 'header-component', + ) + expect(screen.getByRole('main').textContent).toBe( + 'table-componentcell-componentcell:First' + + 'header-componentheader:titlefooter:title', + ) + }) + + test('bound cell render helpers preserve aggregate and placeholder modes', async () => { + const mode = ref<'aggregate' | 'placeholder'>('aggregate') + const CellConsumer = defineComponent({ + setup() { + const cell = hook.useCellContext() + return () => + h('output', { 'aria-label': 'Bound cell mode' }, h(cell.FlexRender)) + }, + }) + const Root = defineComponent({ + setup() { + const table = hook.useAppTable({ + data: [{ id: '1', title: 'First' }], + columns, + }) + const cell = table.getRowModel().rows[0]!.getAllCells()[0]! + + vi.spyOn(cell, 'getIsAggregated').mockImplementation( + () => mode.value === 'aggregate', + ) + vi.spyOn(cell, 'getIsPlaceholder').mockImplementation( + () => mode.value === 'placeholder', + ) + + return () => + h( + table.AppCell, + { cell }, + { + default: () => h(CellConsumer), + }, + ) + }, + }) + + render(Root) + + expect(outputText('Bound cell mode')).toBe('aggregate:First') + + mode.value = 'placeholder' + await nextTick() + + expect(outputText('Bound cell mode')).toBe('') + }) + + test.each([ + ['useTableContext', () => hook.useTableContext()], + ['useCellContext', () => hook.useCellContext()], + ['useHeaderContext', () => hook.useHeaderContext()], + ])('%s throws a focused error outside its provider', (name, readContext) => { + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const Consumer = defineComponent({ + setup() { + readContext() + return () => null + }, + }) + + try { + expect(() => render(Consumer)).toThrowError( + new RegExp(`\\\`${name}\\\` must be used within`), + ) + } finally { + warning.mockRestore() + } + }) +}) diff --git a/packages/vue-table/tests/unit/signals.test.ts b/packages/vue-table/tests/unit/signals.test.ts index 6a572d4b66..d751381c8c 100644 --- a/packages/vue-table/tests/unit/signals.test.ts +++ b/packages/vue-table/tests/unit/signals.test.ts @@ -1,10 +1,7 @@ import { describe, expect, test } from 'vitest' import { nextTick } from 'vue' import { createAtom } from '@tanstack/store' -import { stockFeatures } from '@tanstack/table-core' import { vueReactivity } from '../../src/reactivity' -import { useTable } from '../../src/useTable' -import type { ColumnDef } from '@tanstack/table-core' describe('vueReactivity', () => { test('creates writable and readonly atoms from Vue refs', async () => { @@ -47,33 +44,3 @@ describe('vueReactivity', () => { expect(doubled.get()).toBe(4) }) }) - -describe('table.Subscribe', () => { - type Data = { id: string } - const columns: Array> = [ - { - id: 'id', - accessorKey: 'id', - }, - ] - - test('passes table atoms to children', () => { - const table = useTable({ - data: [{ id: '1' }], - columns, - _features: stockFeatures, - _rowModels: {}, - }) - - let received: unknown - - table.Subscribe({ - children: (atoms) => { - received = atoms - return [] - }, - }) - - expect(received).toBe(table.atoms) - }) -}) diff --git a/packages/vue-table/tests/unit/useTable.test.ts b/packages/vue-table/tests/unit/useTable.test.ts new file mode 100644 index 0000000000..2d1fe92565 --- /dev/null +++ b/packages/vue-table/tests/unit/useTable.test.ts @@ -0,0 +1,354 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { computed, effectScope, nextTick, ref, watchEffect } from 'vue' +import { createPaginatedRowModel, stockFeatures } from '@tanstack/table-core' +import { useTable } from '../../src/useTable' +import type { + ColumnDef, + PaginationState, + RowSelectionState, +} from '@tanstack/table-core' +import type { EffectScope, Ref } from 'vue' + +describe('useTable', () => { + type Data = { id: string; title: string } + + const columns: Array> = [ + { + id: 'id', + header: 'Id', + accessorKey: 'id', + cell: (context) => context.getValue(), + }, + { + id: 'title', + header: 'Title', + accessorKey: 'title', + cell: (context) => context.getValue(), + }, + ] + + let scope: EffectScope + + beforeEach(() => { + scope = effectScope() + }) + + afterEach(() => { + scope.stop() + }) + + function runInScope(fn: () => T): T { + return scope.run(fn)! + } + + function createTestTable( + data: Ref> = ref([{ id: '1', title: 'Title' }]), + ) { + return useTable({ + data, + features: { ...stockFeatures }, + columns, + getRowId: (row) => row.id, + }) + } + + test('accepts reactive data and updates the row model', async () => { + const source = ref>([{ id: '1', title: 'Title' }]) + const data = computed(() => source.value) + const table = runInScope(() => createTestTable(data)) + + expect(table.getRowModel().rows.map((row) => row.id)).toEqual(['1']) + + source.value = [ + { id: '1', title: 'Updated title' }, + { id: '2', title: 'Second title' }, + ] + await nextTick() + + expect(table.getRowModel().rows.map((row) => row.id)).toEqual(['1', '2']) + expect(table.getRow('1').getValue('title')).toBe('Updated title') + }) + + describe('table object', () => { + test('row models react to controlled pagination changes', async () => { + const features = { + ...stockFeatures, + paginatedRowModel: createPaginatedRowModel(), + } + const paginatedColumns: Array> = [ + { id: 'id', accessorKey: 'id' }, + { id: 'title', accessorKey: 'title' }, + ] + const pagination = ref({ + pageSize: 5, + pageIndex: 0, + }) + const state = computed(() => ({ pagination: pagination.value })) + const data = Array.from({ length: 10 }, (_, i) => ({ + id: String(i), + title: `Title ${i}`, + })) + + const table = runInScope(() => { + const table = useTable({ + data, + columns: paginatedColumns, + features, + getRowId: (row) => row.id, + state, + onPaginationChange: (updater) => { + pagination.value = + typeof updater === 'function' + ? updater(pagination.value) + : updater + }, + }) + + return table + }) + + expect(table.getRowModel().rows.map((row) => row.id)).toEqual([ + '0', + '1', + '2', + '3', + '4', + ]) + + pagination.value = { pageIndex: 0, pageSize: 3 } + await nextTick() + + expect(table.getRowModel().rows.map((row) => row.id)).toEqual([ + '0', + '1', + '2', + ]) + }) + }) + + describe('reactivity integration', () => { + test('effects respond to the table inputs they read', async () => { + const data = ref>([{ id: '1', title: 'Title' }]) + const captors = { + isSelectedRow1: vi.fn<(value: boolean) => void>(), + titleValue: vi.fn<(value: unknown) => void>(), + columnIsVisible: vi.fn<(value: boolean) => void>(), + } + + const table = runInScope(() => { + const table = createTestTable(data) + const row = computed(() => table.getRowModel().rows[0]!) + const titleCell = computed(() => row.value.getAllCells()[1]!) + + watchEffect(() => captors.isSelectedRow1(row.value.getIsSelected()), { + flush: 'sync', + }) + watchEffect(() => captors.titleValue(titleCell.value.getValue()), { + flush: 'sync', + }) + watchEffect( + () => captors.columnIsVisible(table.getColumn('id')!.getIsVisible()), + { flush: 'sync' }, + ) + + return table + }) + + expect(captors.isSelectedRow1.mock.calls).toEqual([[false]]) + expect(captors.titleValue.mock.calls).toEqual([['Title']]) + expect(captors.columnIsVisible.mock.calls).toEqual([[true]]) + + Object.values(captors).forEach((captor) => captor.mockClear()) + table.getRow('1').toggleSelected(true) + await nextTick() + + expect(captors.isSelectedRow1.mock.calls).toEqual([[true]]) + expect(captors.titleValue).not.toHaveBeenCalled() + expect(captors.columnIsVisible).not.toHaveBeenCalled() + + Object.values(captors).forEach((captor) => captor.mockClear()) + data.value = [{ id: '1', title: 'Title 3' }] + await nextTick() + + expect(captors.titleValue.mock.lastCall).toEqual(['Title 3']) + + Object.values(captors).forEach((captor) => captor.mockClear()) + table.getColumn('id')!.toggleVisibility(false) + await nextTick() + + expect(captors.columnIsVisible.mock.calls).toEqual([[false]]) + expect(captors.isSelectedRow1).not.toHaveBeenCalled() + expect(captors.titleValue).not.toHaveBeenCalled() + }) + + test('table store can be subscribed from a Vue effect', async () => { + const tableStateCaptor = vi.fn() + const table = runInScope(() => { + const table = createTestTable() + + watchEffect((onCleanup) => { + const subscription = table.store.subscribe(() => { + tableStateCaptor(table.store.get()) + }) + + onCleanup(() => subscription.unsubscribe()) + }) + + return table + }) + + table.toggleAllRowsSelected(true) + await nextTick() + + expect(tableStateCaptor).toHaveBeenCalledTimes(1) + expect(tableStateCaptor.mock.calls[0]![0].rowSelection).toEqual({ + 1: true, + }) + }) + + test('table state reacts to every external ref state update', async () => { + const rowSelection = ref({}) + const state = computed(() => ({ rowSelection: rowSelection.value })) + const tableStateCaptor = vi.fn<(value: RowSelectionState) => void>() + + runInScope(() => { + const table = useTable({ + data: [{ id: '1', title: 'Title' }], + features: { ...stockFeatures }, + columns, + getRowId: (row) => row.id, + state, + }) + + watchEffect(() => tableStateCaptor(table.atoms.rowSelection.get()), { + flush: 'sync', + }) + }) + + rowSelection.value = { 1: true } + await nextTick() + rowSelection.value = { 1: true, 2: true } + await nextTick() + rowSelection.value = { 2: true } + await nextTick() + + expect(tableStateCaptor.mock.calls).toEqual([ + [{}], + [{ 1: true }], + [{ 1: true, 2: true }], + [{ 2: true }], + ]) + }) + + test('tracks controlled slices exposed by getters on a plain state object', async () => { + const rowSelection = ref({}) + const state = { + get rowSelection() { + return rowSelection.value + }, + } + const tableStateCaptor = vi.fn<(value: RowSelectionState) => void>() + + runInScope(() => { + const table = useTable({ + data: [{ id: '1', title: 'Title' }], + features: { ...stockFeatures }, + columns, + getRowId: (row) => row.id, + state, + }) + + watchEffect(() => tableStateCaptor(table.atoms.rowSelection.get()), { + flush: 'sync', + }) + }) + + rowSelection.value = { 1: true } + await nextTick() + + expect(tableStateCaptor.mock.calls).toEqual([[{}], [{ 1: true }]]) + }) + + test('table state reacts to internal table state updates', async () => { + const pageSizeCaptor = vi.fn<(value: number) => void>() + const storePageSizeCaptor = vi.fn<(value: number) => void>() + + const table = runInScope(() => { + const table = useTable({ + data: [{ id: '1', title: 'Title' }], + features: { ...stockFeatures }, + columns, + getRowId: (row) => row.id, + initialState: { + pagination: { + pageIndex: 0, + pageSize: 20, + }, + }, + }) + + watchEffect( + () => pageSizeCaptor(table.atoms.pagination.get().pageSize), + { flush: 'sync' }, + ) + watchEffect( + () => storePageSizeCaptor(table.store.get().pagination.pageSize), + { flush: 'sync' }, + ) + + return table + }) + + expect(pageSizeCaptor.mock.calls).toEqual([[20]]) + expect(storePageSizeCaptor.mock.calls).toEqual([[20]]) + + table.setPageSize(50) + await nextTick() + table.setPageSize(100) + await nextTick() + + expect(pageSizeCaptor.mock.calls).toEqual([[20], [50], [100]]) + expect(storePageSizeCaptor.mock.calls).toEqual([[20], [50], [100]]) + }) + + test('table state property reads track only the accessed slice', async () => { + const pageSizeCaptor = vi.fn<(value: number) => void>() + const stateJsonCaptor = vi.fn<(value: string) => void>() + + const table = runInScope(() => { + const table = useTable({ + data: [{ id: '1', title: 'Title' }], + features: { ...stockFeatures }, + columns, + getRowId: (row) => row.id, + initialState: { + pagination: { + pageIndex: 0, + pageSize: 20, + }, + }, + }) + + watchEffect( + () => pageSizeCaptor(table.atoms.pagination.get().pageSize), + { flush: 'sync' }, + ) + watchEffect( + () => stateJsonCaptor(JSON.stringify(table.store.get(), null, 2)), + { flush: 'sync' }, + ) + + return table + }) + + table.toggleAllRowsSelected(true) + await nextTick() + + expect(pageSizeCaptor.mock.calls).toEqual([[20]]) + expect(stateJsonCaptor).toHaveBeenCalledTimes(2) + expect( + JSON.parse(stateJsonCaptor.mock.calls.at(-1)![0]).rowSelection, + ).toEqual({ 1: true }) + }) + }) +}) diff --git a/packages/vue-table/tsconfig.json b/packages/vue-table/tsconfig.json index 498bd8bfb5..9f42bd4681 100644 --- a/packages/vue-table/tsconfig.json +++ b/packages/vue-table/tsconfig.json @@ -9,5 +9,5 @@ "@tanstack/form-core": ["../form-core/src"] } }, - "include": ["src", "vite.config.ts", "eslint.config.js"] + "include": ["src", "tests", "vite.config.ts", "eslint.config.js"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e6ece96525..a9e73d8496 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13494,6 +13494,9 @@ importers: '@eslint-react/eslint-plugin': specifier: ^5.9.2 version: 5.9.2(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@types/react': specifier: 19.2.16 version: 19.2.16 @@ -13565,6 +13568,9 @@ importers: specifier: workspace:* version: link:../table-core devDependencies: + '@solidjs/testing-library': + specifier: ^0.8.10 + version: 0.8.10(solid-js@1.9.13) solid-js: specifier: ^1.9.13 version: 1.9.13 @@ -13659,6 +13665,9 @@ importers: specifier: workspace:* version: link:../table-core devDependencies: + '@testing-library/vue': + specifier: ^8.1.0 + version: 8.1.0(@vue/compiler-dom@3.5.38)(@vue/compiler-sfc@3.5.38)(@vue/server-renderer@3.5.38(vue@3.5.38(typescript@6.0.3)))(vue@3.5.38(typescript@6.0.3)) '@vitejs/plugin-vue': specifier: ^6.0.7 version: 6.0.7(vite@8.1.4(@types/node@26.0.0)(esbuild@0.28.0)(jiti@2.7.0)(less@4.6.4)(sass@1.100.0)(sugarss@5.0.1(postcss@8.5.16))(terser@5.46.2)(yaml@2.9.0))(vue@3.5.38(typescript@6.0.3)) @@ -18855,6 +18864,16 @@ packages: peerDependencies: solid-js: '>=1.8.4' + '@solidjs/testing-library@0.8.10': + resolution: {integrity: sha512-qdeuIerwyq7oQTIrrKvV0aL9aFeuwTd86VYD3afdq5HYEwoox1OBTJy4y8A3TFZr8oAR0nujYgCzY/8wgHGfeQ==} + engines: {node: '>= 14'} + peerDependencies: + '@solidjs/router': '>=0.9.0' + solid-js: '>=1.0.0' + peerDependenciesMeta: + '@solidjs/router': + optional: true + '@spectrum-icons/ui@3.7.1': resolution: {integrity: sha512-veQymocUYo5OciXQajSailOdbWe+k6+2ehfF8D4d0V923D4xOUadtT253xXZ5vEQjPat6Kyp2WDKeQNjd7kL1w==} peerDependencies: @@ -19567,10 +19586,43 @@ packages: peerDependencies: vue: ^2.7.0 || ^3.0.0 + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/dom@9.3.4': + resolution: {integrity: sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==} + engines: {node: '>=14'} + '@testing-library/jest-dom@6.9.1': resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/vue@8.1.0': + resolution: {integrity: sha512-ls4RiHO1ta4mxqqajWRh8158uFObVrrtAPoxk7cIp4HrnQUj/ScKzqz53HxYpG3X6Zb7H2v+0eTGLSoy8HQ2nA==} + engines: {node: '>=14'} + peerDependencies: + '@vue/compiler-sfc': '>= 3' + vue: '>= 3' + peerDependenciesMeta: + '@vue/compiler-sfc': + optional: true + '@ts-morph/common@0.22.0': resolution: {integrity: sha512-HqNBuV/oIlMKdkLshXd1zKBqNQCsuPEsgQOkfFQ/eUKjRlwndXW1AjN9LVkBEIukm00gGXSRmfkl0Wv5VXLnlw==} @@ -20119,6 +20171,16 @@ packages: '@vue/shared@3.5.38': resolution: {integrity: sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==} + '@vue/test-utils@2.4.11': + resolution: {integrity: sha512-GDqaqZsA6m2E5vNzej0aYiIb6BX8xV9pNSbbbXKOfEYwg7ZNblVX8suyqmUBThq8VIrgAJNxn+z72hVtUeiWHA==} + peerDependencies: + '@vue/compiler-dom': 3.x + '@vue/server-renderer': 3.x + vue: 3.x + peerDependenciesMeta: + '@vue/server-renderer': + optional: true + '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -20548,6 +20610,12 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} + aria-query@5.1.3: + resolution: {integrity: sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + aria-query@5.3.1: resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} engines: {node: '>= 0.4'} @@ -21102,6 +21170,9 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + config-chain@1.1.13: + resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} + connect-history-api-fallback@2.0.0: resolution: {integrity: sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==} engines: {node: '>=0.8'} @@ -21451,6 +21522,10 @@ packages: dedent-js@1.0.1: resolution: {integrity: sha512-OUepMozQULMLUmhxS95Vudo0jb0UchLimi3+pQ2plj61Fcy8axbP9hbiD4Sz6DPqn6XG3kfmziVfQ1rSys5AJQ==} + deep-equal@2.2.3: + resolution: {integrity: sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==} + engines: {node: '>= 0.4'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -21586,6 +21661,11 @@ packages: resolution: {integrity: sha512-ptGvkwTvGdGfC0hfhKg0MT+TRLRKGtUiWGBInxOm5pz7ssADezahjCUaYuZ8Dr+C05FW0AECIIPt4WBxVINEhA==} engines: {node: '>=0.8'} + editorconfig@1.0.7: + resolution: {integrity: sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==} + engines: {node: '>=14'} + hasBin: true + editorconfig@3.0.2: resolution: {integrity: sha512-T0ix8GhtxyKVfUFEcvdNDt3YGqlwkFHbD4/5bgFUDgFmxhI/cSRAeJ87/Sz//Cq8Eam6JX/e23RkoFO71P7aAA==} engines: {node: '>=20'} @@ -21769,6 +21849,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-get-iterator@1.1.3: + resolution: {integrity: sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==} + es-module-lexer@2.1.0: resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} @@ -22743,6 +22826,10 @@ packages: resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==} engines: {node: '>= 10'} + is-arguments@1.2.0: + resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==} + engines: {node: '>= 0.4'} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -23024,6 +23111,11 @@ packages: jose@6.2.3: resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + js-beautify@1.15.4: + resolution: {integrity: sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==} + engines: {node: '>=14'} + hasBin: true + js-string-escape@1.0.1: resolution: {integrity: sha512-Smw4xcfIQ5LVjAOuJCvN/zIodzA/BBSsluuoSykP+lUvScIi4U6RJLfwHet5cxFnCswUjISV8oAXaqaJDY3chg==} engines: {node: '>= 0.8'} @@ -23709,6 +23801,11 @@ packages: resolution: {integrity: sha512-3l4E8uMPY1HdMMryPRUAl+oIHtXtyiTlIiESNSVSNxcPfzAFzeTbXFQkZfAwBbo0B1qMSG8nUABx+Gd+YrbKrQ==} engines: {node: '>=6'} + nopt@7.2.1: + resolution: {integrity: sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + hasBin: true + nopt@9.0.0: resolution: {integrity: sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==} engines: {node: ^20.17.0 || >=22.9.0} @@ -23784,6 +23881,10 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} + object-is@1.1.6: + resolution: {integrity: sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==} + engines: {node: '>= 0.4'} + object-keys@1.1.1: resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} engines: {node: '>= 0.4'} @@ -24229,6 +24330,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pretty-ms@9.3.0: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} @@ -24956,6 +25061,10 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -26312,8 +26421,78 @@ packages: zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + '@one-ini/wasm@0.1.1': + resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + abbrev@2.0.0: + resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + js-cookie@3.0.8: + resolution: {integrity: sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + proto-list@1.2.4: + resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + vue-component-type-helpers@3.3.8: + resolution: {integrity: sha512-troqCMmQodQDqUqn63NQaFi+CDSclSe7sc8VEBFqf5GFLqmGR2Ph3P2WEC7qwpRVyEWsTi/aAr4vyOe/B1hU3g==} + snapshots: + '@one-ini/wasm@0.1.1': {} + + '@types/aria-query@5.0.4': {} + + abbrev@2.0.0: {} + + ansi-styles@5.2.0: {} + + commander@10.0.1: {} + + dequal@2.0.3: {} + + dom-accessibility-api@0.5.16: {} + + ini@1.3.8: {} + + js-cookie@3.0.8: {} + + lz-string@1.5.0: {} + + proto-list@1.2.4: {} + + react-is@17.0.2: {} + + vue-component-type-helpers@3.3.8: {} + '@adobe/css-tools@4.5.0': {} '@adobe/react-spectrum-ui@1.2.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': @@ -32291,6 +32470,11 @@ snapshots: dependencies: solid-js: 1.9.13 + '@solidjs/testing-library@0.8.10(solid-js@1.9.13)': + dependencies: + '@testing-library/dom': 10.4.1 + solid-js: 1.9.13 + '@spectrum-icons/ui@3.7.1(@adobe/react-spectrum@3.47.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@adobe/react-spectrum': 3.47.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -33156,6 +33340,28 @@ snapshots: '@tanstack/virtual-core': 3.17.3 vue: 3.5.38(typescript@6.0.3) + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/dom@9.3.4': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.1.3 + chalk: 4.1.2 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + pretty-format: 27.5.1 + '@testing-library/jest-dom@6.9.1': dependencies: '@adobe/css-tools': 4.5.0 @@ -33165,6 +33371,28 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + + '@testing-library/vue@8.1.0(@vue/compiler-dom@3.5.38)(@vue/compiler-sfc@3.5.38)(@vue/server-renderer@3.5.38(vue@3.5.38(typescript@6.0.3)))(vue@3.5.38(typescript@6.0.3))': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 9.3.4 + '@vue/test-utils': 2.4.11(@vue/compiler-dom@3.5.38)(@vue/server-renderer@3.5.38(vue@3.5.38(typescript@6.0.3)))(vue@3.5.38(typescript@6.0.3)) + vue: 3.5.38(typescript@6.0.3) + optionalDependencies: + '@vue/compiler-sfc': 3.5.38 + transitivePeerDependencies: + - '@vue/compiler-dom' + - '@vue/server-renderer' + '@ts-morph/common@0.22.0': dependencies: fast-glob: 3.3.3 @@ -33880,6 +34108,15 @@ snapshots: '@vue/shared@3.5.38': {} + '@vue/test-utils@2.4.11(@vue/compiler-dom@3.5.38)(@vue/server-renderer@3.5.38(vue@3.5.38(typescript@6.0.3)))(vue@3.5.38(typescript@6.0.3))': + dependencies: + '@vue/compiler-dom': 3.5.38 + js-beautify: 1.15.4 + vue: 3.5.38(typescript@6.0.3) + vue-component-type-helpers: 3.3.8 + optionalDependencies: + '@vue/server-renderer': 3.5.38(vue@3.5.38(typescript@6.0.3)) + '@webassemblyjs/ast@1.14.1': dependencies: '@webassemblyjs/helper-numbers': 1.13.2 @@ -34658,6 +34895,14 @@ snapshots: dependencies: tslib: 2.8.1 + aria-query@5.1.3: + dependencies: + deep-equal: 2.2.3 + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + aria-query@5.3.1: {} aria-query@5.3.2: {} @@ -35386,6 +35631,11 @@ snapshots: concat-map@0.0.1: {} + config-chain@1.1.13: + dependencies: + ini: 1.3.8 + proto-list: 1.2.4 + connect-history-api-fallback@2.0.0: {} consolidate@1.0.4(@babel/core@7.29.7)(handlebars@4.7.9)(lodash@4.18.1)(mustache@4.2.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(underscore@1.13.8): @@ -35612,6 +35862,27 @@ snapshots: dedent-js@1.0.1: {} + deep-equal@2.2.3: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.9 + es-get-iterator: 1.1.3 + get-intrinsic: 1.3.0 + is-arguments: 1.2.0 + is-array-buffer: 3.0.5 + is-date-object: 1.1.0 + is-regex: 1.2.1 + is-shared-array-buffer: 1.0.4 + isarray: 2.0.5 + object-is: 1.1.6 + object-keys: 1.1.1 + object.assign: 4.1.7 + regexp.prototype.flags: 1.5.4 + side-channel: 1.1.1 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.22 + deep-is@0.1.4: {} deepmerge@4.3.1: {} @@ -35729,6 +36000,13 @@ snapshots: errlop: 2.2.0 semver: 6.3.1 + editorconfig@1.0.7: + dependencies: + '@one-ini/wasm': 0.1.1 + commander: 10.0.1 + minimatch: 9.0.9 + semver: 7.8.5 + editorconfig@3.0.2: dependencies: '@one-ini/wasm': 0.2.1 @@ -36027,6 +36305,18 @@ snapshots: es-errors@1.3.0: {} + es-get-iterator@1.1.3: + dependencies: + call-bind: 1.0.9 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + is-arguments: 1.2.0 + is-map: 2.0.3 + is-set: 2.0.3 + is-string: 1.1.1 + isarray: 2.0.5 + stop-iteration-iterator: 1.1.0 + es-module-lexer@2.1.0: {} es-object-atoms@1.1.1: @@ -37300,6 +37590,11 @@ snapshots: ipaddr.js@2.4.0: {} + is-arguments@1.2.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.9 @@ -37571,6 +37866,14 @@ snapshots: jose@6.2.3: {} + js-beautify@1.15.4: + dependencies: + config-chain: 1.1.13 + editorconfig: 1.0.7 + glob: 10.5.0 + js-cookie: 3.0.8 + nopt: 7.2.1 + js-string-escape@1.0.1: {} js-tokens@10.0.0: {} @@ -38308,6 +38611,10 @@ snapshots: node-watch@0.7.3: {} + nopt@7.2.1: + dependencies: + abbrev: 2.0.0 + nopt@9.0.0: dependencies: abbrev: 4.0.0 @@ -38503,6 +38810,11 @@ snapshots: object-inspect@1.13.4: {} + object-is@1.1.6: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + object-keys@1.1.1: {} object.assign@4.1.7: @@ -39090,6 +39402,12 @@ snapshots: prettier@3.8.4: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + pretty-ms@9.3.0: dependencies: parse-ms: 4.0.0 @@ -40061,6 +40379,14 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} signal-exit@3.0.7: {}