From 619d1af73728a662447740b0f7e3bd99c9494929 Mon Sep 17 00:00:00 2001 From: riccardoperra Date: Thu, 30 Jul 2026 00:10:43 +0200 Subject: [PATCH 1/3] add test for alpine, ember, lit, preact, svelte --- knip.json | 1 + packages/alpine-table/package.json | 3 + packages/alpine-table/src/flexRender.ts | 23 +- .../tests/unit/adapterCoverage.test.ts | 412 ++++++++++++ packages/alpine-table/tsconfig.json | 2 +- packages/ember-table/src/FlexRender.gts | 29 +- packages/ember-table/src/create-table-hook.ts | 39 +- packages/ember-table/src/use-table.ts | 68 +- .../integration/create-table-hook.test.gts | 2 +- .../tests/integration/external-state.test.gts | 281 +++++++- .../tests/integration/flex-render.test.gts | 89 +++ .../tests/integration/reactivity.test.gts | 153 +++++ packages/lit-table/package.json | 3 + packages/lit-table/src/TableController.ts | 3 + packages/lit-table/src/createTableHook.ts | 52 +- packages/lit-table/src/flexRender.ts | 54 +- packages/lit-table/src/subscribe-directive.ts | 3 + .../tests/unit/adapterLifecycle.test.ts | 377 +++++++++++ .../tests/unit/defaultReactivity.test.ts | 2 + .../lit-table/tests/unit/flexRender.test.ts | 29 + .../lit-table/tests/unit/rendering.test.ts | 460 +++++++++++++ .../lit-table/tests/unit/selectorGate.test.ts | 2 + packages/lit-table/tsconfig.json | 2 +- packages/preact-table/package.json | 4 +- packages/preact-table/src/FlexRender.tsx | 19 +- .../tests/unit/adapterReactivity.test.tsx | 609 ++++++++++++++++++ .../tests/unit/createTableHook.test.tsx | 247 +++++++ .../tests/unit/rendering.test.tsx | 232 +++++++ packages/preact-table/tests/unit/ssr.test.tsx | 164 +++++ .../preact-table/tests/unit/useTable.test.tsx | 331 +++++++++- .../tests/adapterReactivity.test.tsx | 124 +++- packages/svelte-table/package.json | 5 +- packages/svelte-table/src/FlexRender.svelte | 16 +- .../svelte-table/src/createTable.svelte.ts | 15 +- .../src/createTableHook.svelte.ts | 199 ++++-- packages/svelte-table/src/index.ts | 1 + .../svelte-table/src/reactivity.svelte.ts | 25 +- .../tests/adapter-lifecycle.test.ts | 227 +++++++ .../tests/fixtures/CallbackHarness.svelte | 34 + .../tests/fixtures/ContextFailure.svelte | 11 + .../tests/fixtures/FlexRenderHarness.svelte | 88 +++ .../tests/fixtures/HookCellBadge.svelte | 7 + .../tests/fixtures/HookHarness.svelte | 80 +++ .../tests/fixtures/HookHeaderBadge.svelte | 7 + .../tests/fixtures/HookTableBadge.svelte | 8 + .../tests/fixtures/PaginationHarness.svelte | 45 ++ .../tests/fixtures/ReactivityHarness.svelte | 152 +++++ .../tests/fixtures/RenderBadge.svelte | 9 + .../tests/fixtures/SelectorHarness.svelte | 47 ++ .../tests/fixtures/SsrHarness.svelte | 57 ++ .../tests/fixtures/hook-fixture.ts | 16 + packages/svelte-table/tests/rendering.test.ts | 234 +++++++ packages/svelte-table/tests/ssr.test.ts | 16 + packages/svelte-table/tests/test-setup.ts | 1 + packages/svelte-table/tsconfig.json | 6 +- packages/svelte-table/vite.config.ts | 6 + pnpm-lock.yaml | 79 +++ 57 files changed, 5034 insertions(+), 176 deletions(-) create mode 100644 packages/alpine-table/tests/unit/adapterCoverage.test.ts create mode 100644 packages/lit-table/tests/unit/adapterLifecycle.test.ts create mode 100644 packages/lit-table/tests/unit/flexRender.test.ts create mode 100644 packages/lit-table/tests/unit/rendering.test.ts create mode 100644 packages/preact-table/tests/unit/adapterReactivity.test.tsx create mode 100644 packages/preact-table/tests/unit/createTableHook.test.tsx create mode 100644 packages/preact-table/tests/unit/rendering.test.tsx create mode 100644 packages/preact-table/tests/unit/ssr.test.tsx create mode 100644 packages/svelte-table/tests/adapter-lifecycle.test.ts create mode 100644 packages/svelte-table/tests/fixtures/CallbackHarness.svelte create mode 100644 packages/svelte-table/tests/fixtures/ContextFailure.svelte create mode 100644 packages/svelte-table/tests/fixtures/FlexRenderHarness.svelte create mode 100644 packages/svelte-table/tests/fixtures/HookCellBadge.svelte create mode 100644 packages/svelte-table/tests/fixtures/HookHarness.svelte create mode 100644 packages/svelte-table/tests/fixtures/HookHeaderBadge.svelte create mode 100644 packages/svelte-table/tests/fixtures/HookTableBadge.svelte create mode 100644 packages/svelte-table/tests/fixtures/PaginationHarness.svelte create mode 100644 packages/svelte-table/tests/fixtures/ReactivityHarness.svelte create mode 100644 packages/svelte-table/tests/fixtures/RenderBadge.svelte create mode 100644 packages/svelte-table/tests/fixtures/SelectorHarness.svelte create mode 100644 packages/svelte-table/tests/fixtures/SsrHarness.svelte create mode 100644 packages/svelte-table/tests/fixtures/hook-fixture.ts create mode 100644 packages/svelte-table/tests/rendering.test.ts create mode 100644 packages/svelte-table/tests/ssr.test.ts create mode 100644 packages/svelte-table/tests/test-setup.ts diff --git a/knip.json b/knip.json index a7f3dfc986..69199651ff 100644 --- a/knip.json +++ b/knip.json @@ -20,6 +20,7 @@ "tests/test-helper.ts" ], "ignoreDependencies": [ + "@ember/destroyable", "@ember/library-tsconfig", "@ember/routing", "@embroider/shared-internals", diff --git a/packages/alpine-table/package.json b/packages/alpine-table/package.json index f432d93537..835528ffbb 100644 --- a/packages/alpine-table/package.json +++ b/packages/alpine-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/dom": "^10.4.1", "@types/alpinejs": "^3.13.11", "alpinejs": "^3.15.12" }, diff --git a/packages/alpine-table/src/flexRender.ts b/packages/alpine-table/src/flexRender.ts index 095bf9fcb7..f113a6ba9f 100644 --- a/packages/alpine-table/src/flexRender.ts +++ b/packages/alpine-table/src/flexRender.ts @@ -79,7 +79,28 @@ export function FlexRender< TValue extends CellData = CellData, >(props: FlexRenderProps): any { if ('cell' in props && props.cell) { - return flexRender(props.cell.column.columnDef.cell, props.cell.getContext()) + const cell = props.cell + const definition = cell.column.columnDef + const groupingCell = cell as typeof cell & { + getIsAggregated?: () => boolean + getIsPlaceholder?: () => boolean + } + const groupingDefinition = definition as typeof definition & { + aggregatedCell?: typeof definition.cell + } + + if (groupingCell.getIsAggregated?.()) { + return flexRender( + groupingDefinition.aggregatedCell ?? definition.cell, + cell.getContext(), + ) + } + + if (groupingCell.getIsPlaceholder?.()) { + return null + } + + return flexRender(definition.cell, cell.getContext()) } if ('header' in props && props.header) { diff --git a/packages/alpine-table/tests/unit/adapterCoverage.test.ts b/packages/alpine-table/tests/unit/adapterCoverage.test.ts new file mode 100644 index 0000000000..a60d460bb1 --- /dev/null +++ b/packages/alpine-table/tests/unit/adapterCoverage.test.ts @@ -0,0 +1,412 @@ +// @vitest-environment jsdom +import Alpine from 'alpinejs' +import { afterEach, describe, expect, test, vi } from 'vitest' +import { screen } from '@testing-library/dom' +import { createAtom } from '@tanstack/store' +import { stockFeatures } from '@tanstack/table-core' +import { createTable } from '../../src/createTable' +import { createTableHook } from '../../src/createTableHook' +import type { Cell, ColumnDef, RowSelectionState } from '@tanstack/table-core' + +type Data = { id: string; title: string } + +const idColumn: ColumnDef = { + id: 'id', + accessorKey: 'id', + header: 'Identifier', + footer: 'Identifier footer', + cell: (context) => `${context.getValue()}`, + aggregatedCell: (context) => `Aggregate ${context.getValue()}`, +} + +const titleColumn: ColumnDef = { + id: 'title', + accessorKey: 'title', +} + +const mountedRoots: Array<{ + root: HTMLElement + removeScope: () => void +}> = [] + +function flushEffects() { + return new Promise((resolve) => { + setTimeout(resolve, 0) + }) +} + +function mountAlpine( + markup: string, + scope: Record, +): HTMLElement { + const template = document.createElement('template') + template.innerHTML = markup.trim() + const root = template.content.firstElementChild as HTMLElement + document.body.append(root) + + const removeScope = Alpine.addScopeToNode(root, scope) + mountedRoots.push({ root, removeScope }) + Alpine.initTree(root) + + return root +} + +function destroyRoot(root: HTMLElement, remove = true) { + const mounted = mountedRoots.find((entry) => entry.root === root) + if (!mounted) return + + Alpine.destroyTree(root) + mounted.removeScope() + if (remove) root.remove() + mountedRoots.splice(mountedRoots.indexOf(mounted), 1) +} + +afterEach(() => { + for (const { root } of [...mountedRoots]) { + destroyRoot(root) + } + document.body.replaceChildren() + vi.restoreAllMocks() +}) + +describe('Alpine adapter reactivity', () => { + test('releases and reacquires controlled ownership one state slice at a time', async () => { + const options = Alpine.reactive<{ + state: { rowSelection?: RowSelectionState } + }>({ + state: { rowSelection: { 1: true } }, + }) + const table = createTable({ + data: [{ id: '1', title: 'First' }], + columns: [idColumn, titleColumn], + features: stockFeatures, + getRowId: (row) => row.id, + get state() { + return options.state + }, + }) + + expect(table.atoms.rowSelection.get()).toEqual({ 1: true }) + + table.toggleAllRowsSelected(false) + expect(table.atoms.rowSelection.get()).toEqual({ 1: true }) + + options.state = {} + await flushEffects() + expect(table.atoms.rowSelection.get()).toEqual({}) + + table.toggleAllRowsSelected(true) + expect(table.atoms.rowSelection.get()).toEqual({ 1: true }) + + options.state = { rowSelection: {} } + await flushEffects() + expect(table.atoms.rowSelection.get()).toEqual({}) + }) + + test('gives an external atom precedence and routes table writes back to it', async () => { + const options = Alpine.reactive<{ rowSelection: RowSelectionState }>({ + rowSelection: { 2: true }, + }) + const externalSelection = createAtom({ 1: true }) + const table = createTable({ + data: [ + { id: '1', title: 'First' }, + { id: '2', title: 'Second' }, + ], + columns: [idColumn, titleColumn], + features: stockFeatures, + getRowId: (row) => row.id, + state: { + get rowSelection() { + return options.rowSelection + }, + }, + atoms: { + rowSelection: externalSelection, + }, + }) + + expect(table.atoms.rowSelection.get()).toEqual({ 1: true }) + + options.rowSelection = { 1: true, 2: true } + await flushEffects() + expect(table.atoms.rowSelection.get()).toEqual({ 1: true }) + + externalSelection.set({ 2: true }) + expect(table.getRow('1').getIsSelected()).toBe(false) + expect(table.getRow('2').getIsSelected()).toBe(true) + + table.toggleAllRowsSelected(true) + expect(externalSelection.get()).toEqual({ 1: true, 2: true }) + }) + + test('never exposes partial snapshots during rapid option changes', async () => { + const options = Alpine.reactive<{ + columns: Array> + data: Array + enableRowSelection: boolean + }>({ + columns: [idColumn], + data: [{ id: '1', title: 'Initial' }], + enableRowSelection: true, + }) + const table = createTable({ + features: stockFeatures, + get columns() { + return options.columns + }, + get data() { + return options.data + }, + get enableRowSelection() { + return options.enableRowSelection + }, + getRowId: (row) => row.id, + }) + const snapshotCaptor = + vi.fn< + (snapshot: { + canSelect: boolean + columnIds: Array + values: Array + }) => void + >() + + Alpine.effect(() => { + const row = table.getRowModel().rows[0]! + snapshotCaptor({ + canSelect: row.getCanSelect(), + columnIds: table.getAllLeafColumns().map((column) => column.id), + values: row.getAllCells().map((cell) => cell.getValue()), + }) + }) + + options.data = [{ id: '2', title: 'Intermediate' }] + options.columns = [idColumn, titleColumn] + options.enableRowSelection = false + options.data = [{ id: '3', title: 'Final' }] + options.columns = [titleColumn] + await flushEffects() + + expect(snapshotCaptor.mock.calls[0]).toEqual([ + { + canSelect: true, + columnIds: ['id'], + values: ['1'], + }, + ]) + expect(snapshotCaptor.mock.calls.slice(1)).not.toHaveLength(0) + expect( + snapshotCaptor.mock.calls.slice(1).every(([snapshot]) => { + return ( + snapshot.canSelect === false && + snapshot.columnIds.length === 1 && + snapshot.columnIds[0] === 'title' && + snapshot.values.length === 1 && + snapshot.values[0] === 'Final' + ) + }), + ).toBe(true) + }) + + test('uses the latest reactive option callback', async () => { + const firstHandler = vi.fn() + const secondHandler = vi.fn() + const options = Alpine.reactive({ + onRowSelectionChange: firstHandler, + }) + const table = createTable({ + data: [{ id: '1', title: 'First' }], + columns: [idColumn], + features: stockFeatures, + getRowId: (row) => row.id, + get onRowSelectionChange() { + return options.onRowSelectionChange + }, + }) + + table.toggleAllRowsSelected(true) + expect(firstHandler).toHaveBeenCalledTimes(1) + + options.onRowSelectionChange = secondHandler + await flushEffects() + table.toggleAllRowsSelected(false) + + expect(firstHandler).toHaveBeenCalledTimes(1) + expect(secondHandler).toHaveBeenCalledTimes(1) + }) + + test('stops Alpine DOM bindings when their tree is destroyed', async () => { + const table = createTable({ + data: [{ id: '1', title: 'First' }], + columns: [idColumn], + features: stockFeatures, + getRowId: (row) => row.id, + }) + const root = mountAlpine( + `
`, + { table }, + ) + + expect(screen.getByRole('status', { name: 'selection' }).textContent).toBe( + 'clear', + ) + + destroyRoot(root, false) + table.setRowSelection({ 1: true }) + await flushEffects() + + expect(screen.getByRole('status', { name: 'selection' }).textContent).toBe( + 'clear', + ) + }) +}) + +describe('Alpine rendering and application hook', () => { + function createCell() { + const table = createTable({ + data: [{ id: '1', title: 'First' }], + columns: [idColumn], + features: stockFeatures, + getRowId: (row) => row.id, + }) + return table.getRowModel().rows[0]!.getAllCells()[0]! + } + + function setCellMode( + cell: Cell, + mode: 'normal' | 'aggregate' | 'placeholder', + ) { + vi.spyOn(cell, 'getIsAggregated').mockReturnValue(mode === 'aggregate') + vi.spyOn(cell, 'getIsPlaceholder').mockReturnValue(mode === 'placeholder') + } + + test('renders normal, aggregate, placeholder, header, and footer content', () => { + const normalCell = createCell() + const aggregateCell = createCell() + const placeholderCell = createCell() + setCellMode(normalCell, 'normal') + setCellMode(aggregateCell, 'aggregate') + setCellMode(placeholderCell, 'placeholder') + + const table = createTable({ + data: [{ id: '1', title: 'First' }], + columns: [idColumn], + features: stockFeatures, + getRowId: (row) => row.id, + }) + const header = table.getHeaderGroups()[0]!.headers[0]! + + mountAlpine( + `
+
+
+
+
+
+
`, + { + aggregateCell, + header, + normalCell, + placeholderCell, + table, + }, + ) + + expect(screen.getByRole('status', { name: 'normal' }).textContent).toBe('1') + expect(screen.getByRole('status', { name: 'aggregate' }).textContent).toBe( + 'Aggregate 1', + ) + expect( + screen.getByRole('status', { name: 'placeholder' }).textContent, + ).toBe('') + expect(screen.getByRole('status', { name: 'header' }).textContent).toBe( + 'Identifier', + ) + expect(screen.getByRole('status', { name: 'footer' }).textContent).toBe( + 'Identifier footer', + ) + }) + + test('updates rendered cell content when reactive data replaces its cell', async () => { + const options = Alpine.reactive({ + data: [{ id: '1', title: 'First' }], + }) + const table = createTable({ + get data() { + return options.data + }, + columns: [titleColumn], + features: stockFeatures, + getRowId: (row) => row.id, + }) + + mountAlpine( + '
', + { table }, + ) + expect(screen.getByRole('status', { name: 'cell value' }).textContent).toBe( + 'First', + ) + + options.data = [{ id: '2', title: 'Second' }] + await flushEffects() + + expect(screen.getByRole('status', { name: 'cell value' }).textContent).toBe( + 'Second', + ) + }) + + test('updates mounted renderer content for the atom read by the cell', async () => { + const selectionColumn: ColumnDef = { + id: 'selection', + cell: (context) => + context.table.atoms.rowSelection.get()['1'] ? 'selected' : 'clear', + } + const table = createTable({ + data: [{ id: '1', title: 'First' }], + columns: [selectionColumn], + features: stockFeatures, + getRowId: (row) => row.id, + }) + + mountAlpine( + '
', + { table }, + ) + expect( + screen.getByRole('status', { name: 'selection renderer' }).textContent, + ).toBe('clear') + + table.setRowSelection({ 1: true }) + await flushEffects() + + expect( + screen.getByRole('status', { name: 'selection renderer' }).textContent, + ).toBe('selected') + }) + + test('binds hook features, defaults, column helpers, and render helpers', () => { + const hook = createTableHook({ + features: stockFeatures, + enableRowSelection: false, + }) + const columnHelper = hook.createAppColumnHelper() + const appColumns = columnHelper.columns([ + columnHelper.accessor('title', {}), + ]) + const table = hook.createAppTable({ + data: [{ id: '1', title: 'First' }], + columns: appColumns, + enableRowSelection: true, + getRowId: (row) => row.id, + }) + + expect(hook.appFeatures).toBe(stockFeatures) + expect(table.getRow('1').getCanSelect()).toBe(true) + expect(table.getRow('1').getValue('title')).toBe('First') + expect(table.FlexRender).toBeDefined() + expect(table.flexRender).toBeDefined() + }) +}) diff --git a/packages/alpine-table/tsconfig.json b/packages/alpine-table/tsconfig.json index eb63835950..d69d19f8e1 100644 --- a/packages/alpine-table/tsconfig.json +++ b/packages/alpine-table/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "../../tsconfig.json", - "include": ["src", "eslint.config.js", "vite.config.ts"] + "include": ["src", "tests", "eslint.config.js", "vite.config.ts"] } diff --git a/packages/ember-table/src/FlexRender.gts b/packages/ember-table/src/FlexRender.gts index 98215dd829..a8168139d3 100644 --- a/packages/ember-table/src/FlexRender.gts +++ b/packages/ember-table/src/FlexRender.gts @@ -78,10 +78,31 @@ export class FlexRenderCell< @cached get result(): CellRenderResult { const cell = this.args.cell - return flexRender( - cell.column.columnDef.cell, - cell.getContext(), - ) as CellRenderResult + const definition = cell.column.columnDef + const groupingCell = cell as typeof cell & { + getIsAggregated?: () => boolean + getIsPlaceholder?: () => boolean + } + const groupingDefinition = definition as typeof definition & { + aggregatedCell?: typeof definition.cell + } + + if (groupingCell.getIsAggregated?.()) { + return flexRender( + groupingDefinition.aggregatedCell ?? definition.cell, + cell.getContext(), + ) as CellRenderResult + } + + if (groupingCell.getIsPlaceholder?.()) { + return null + } + + return flexRender(definition.cell, cell.getContext()) as CellRenderResult< + TFeatures, + TData, + TValue + > } get resolvedContext(): CellContext { diff --git a/packages/ember-table/src/create-table-hook.ts b/packages/ember-table/src/create-table-hook.ts index a3a2ec894a..bc6d91da2b 100644 --- a/packages/ember-table/src/create-table-hook.ts +++ b/packages/ember-table/src/create-table-hook.ts @@ -45,8 +45,8 @@ export type AppColumnHelper< * const columnHelper = createAppColumnHelper() * const columns = columnHelper.columns([...]) * - * // inside a Glimmer component; options stay a thunk so tracked reads are reactive - * table = createAppTable(() => ({ columns, data: this.data })) + * // inside a Glimmer component; passing `this` binds cleanup to its lifecycle + * table = createAppTable(this, () => ({ columns, data: this.data })) * ``` */ export function createTableHook({ @@ -60,19 +60,40 @@ export function createTableHook({ } function createAppTable( + owner: object, getTableOptions: () => Omit, 'features'>, + ): AppEmberTable + function createAppTable( + getTableOptions: () => Omit, 'features'>, + ): AppEmberTable + function createAppTable( + ownerOrGetTableOptions: + | object + | (() => Omit, 'features'>), + maybeGetTableOptions?: () => Omit< + TableOptions, + 'features' + >, ): AppEmberTable { + const hasOwner = maybeGetTableOptions !== undefined + const owner = hasOwner ? ownerOrGetTableOptions : undefined + const getTableOptions = ( + hasOwner ? maybeGetTableOptions : ownerOrGetTableOptions + ) as () => Omit, 'features'> + // Keep options a thunk: the merge runs inside `useTable`'s options thunk, // so tracked properties read in `getTableOptions` stay reactive. Per-table // options take precedence over the shared defaults (except `features`, // which only the hook provides). - return useTable( - () => - ({ - ...defaultTableOptions, - ...getTableOptions(), - }) as TableOptions, - ) + const getMergedOptions = () => + ({ + ...defaultTableOptions, + ...getTableOptions(), + }) as TableOptions + + return owner + ? useTable(owner, getMergedOptions) + : useTable(getMergedOptions) } return { diff --git a/packages/ember-table/src/use-table.ts b/packages/ember-table/src/use-table.ts index 2c444447b9..6630fd29a6 100644 --- a/packages/ember-table/src/use-table.ts +++ b/packages/ember-table/src/use-table.ts @@ -1,4 +1,5 @@ import { constructTable } from '@tanstack/table-core' +import { registerDestructor } from '@ember/destroyable' import { untrack } from '@glimmer/validator' import { emberReactivity } from './reactivity.ts' import { computed, subscribeNoEffect } from './signal.ts' @@ -22,14 +23,47 @@ interface TableInternals< get(): TableOptions set(value: () => TableOptions): void } - baseAtoms: Record + baseAtoms: Record< + string, + { + get(): unknown + set(value: unknown): void + } + > atoms: Record } +/** + * Creates an Ember-reactive table. + * + * Pass the containing component (or another Ember destroyable) as the first + * argument to tie external-atom subscriptions to its lifecycle. The one-arg + * form remains available for standalone tables that do not have an Ember + * owner. + */ +export function useTable< + TFeatures extends TableFeatures, + TData extends RowData, +>( + owner: object, + getOptions: () => TableOptions, +): Table export function useTable< TFeatures extends TableFeatures, TData extends RowData, ->(getOptions: () => TableOptions): Table { +>(getOptions: () => TableOptions): Table +export function useTable< + TFeatures extends TableFeatures, + TData extends RowData, +>( + ownerOrGetOptions: object | (() => TableOptions), + maybeGetOptions?: () => TableOptions, +): Table { + const hasOwner = maybeGetOptions !== undefined + const owner = hasOwner ? ownerOrGetOptions : undefined + const getOptions = ( + hasOwner ? maybeGetOptions : ownerOrGetOptions + ) as () => TableOptions const reactivity = emberReactivity() // Creates reactive read only signal for options @@ -68,6 +102,30 @@ export function useTable< } }) + const getLiveOptions = () => { + const options = liveOptions.get() + const controlledState = options.state as Record | undefined + + // Keep the writable fallback aligned with every controlled value that has + // been observed. This makes a later ownership release expose the latest + // controlled snapshot and makes functional table updaters start from that + // snapshot after control is reacquired. + if (controlledState) { + untrack(() => { + for (const key in controlledState) { + const baseAtom = table.baseAtoms[key] + const controlledValue = controlledState[key] + + if (baseAtom && baseAtom.get() !== controlledValue) { + baseAtom.set(() => controlledValue) + } + } + }) + } + + return options + } + /** * This is to get around core table not using lazy access so we need to re-wrap * @@ -76,7 +134,7 @@ export function useTable< Object.defineProperty(table, 'options', { configurable: true, enumerable: true, - get: () => liveOptions.get(), + get: getLiveOptions, set: (value: TableOptions) => { optionsStore.set(() => value) }, @@ -150,5 +208,9 @@ export function useTable< subscribe: subscribeNoEffect, } + if (owner) { + registerDestructor(owner, () => reactivity.unmount?.()) + } + return table } diff --git a/packages/ember-table/tests/integration/create-table-hook.test.gts b/packages/ember-table/tests/integration/create-table-hook.test.gts index ae8014c279..dfc9bf10d9 100644 --- a/packages/ember-table/tests/integration/create-table-hook.test.gts +++ b/packages/ember-table/tests/integration/create-table-hook.test.gts @@ -69,7 +69,7 @@ module('Integration | createTableHook', function (hooks) { ) // No `features` here: the hook supplies them. - table = createAppTable(() => ({ + table = createAppTable(this, () => ({ columns, data: this.data, })) diff --git a/packages/ember-table/tests/integration/external-state.test.gts b/packages/ember-table/tests/integration/external-state.test.gts index cce57910ca..ed8a50f102 100644 --- a/packages/ember-table/tests/integration/external-state.test.gts +++ b/packages/ember-table/tests/integration/external-state.test.gts @@ -1,5 +1,5 @@ import { module, test } from 'qunit' -import { render, click } from '@ember/test-helpers' +import { click, render, settled } from '@ember/test-helpers' import { setupRenderingTest } from 'ember-qunit' import Component from '@glimmer/component' import { tracked } from '@glimmer/tracking' @@ -20,6 +20,7 @@ import { type Cell, type SortingState, type PaginationState, + type Table, } from '#src/index.ts' // --- Shared fixture --- @@ -224,6 +225,102 @@ module('Integration | external state (controlled)', function (hooks) { .exists({ count: 3 }, 'row model recomputed from the external mutation') }) + test('releases and reacquires ownership when a controlled slice is omitted', async function (assert) { + class TableComponent extends Component { + @tracked state: { pagination?: PaginationState } = { + pagination: { pageIndex: 1, pageSize: 1 }, + } + + table = useTable(() => ({ + data: makeData( + { firstName: 'A' }, + { firstName: 'B' }, + { firstName: 'C' }, + { firstName: 'D' }, + ), + columns, + features, + state: this.state, + autoResetPageIndex: false, + })) + + get pageIndex() { + return this.table.store.state.pagination.pageIndex + } + + advance = () => { + this.table.nextPage() + } + + release = () => { + this.state = {} + } + + reacquire = () => { + this.state = { + pagination: { pageIndex: 0, pageSize: 1 }, + } + } + + + } + + await render() + + assert + .dom('[role="status"][aria-label="Page index"]') + .hasText('1', 'the controlled slice supplies the initial value') + + await click('[aria-label="Advance page"]') + assert + .dom('[role="status"][aria-label="Page index"]') + .hasText('1', 'an internal write cannot replace a controlled value') + + await click('[aria-label="Release pagination"]') + assert + .dom('[role="status"][aria-label="Page index"]') + .hasText('2', 'omitting the slice exposes its latest internal value') + + await click('[aria-label="Advance page"]') + assert.dom('[role="status"][aria-label="Page index"]').hasText('3') + + await click('[aria-label="Control pagination"]') + assert + .dom('[role="status"][aria-label="Page index"]') + .hasText('0', 'the external slice can take ownership again') + + await click('[aria-label="Advance page"]') + assert.dom('[role="status"][aria-label="Page index"]').hasText('0') + + await click('[aria-label="Release pagination"]') + assert + .dom('[role="status"][aria-label="Page index"]') + .hasText( + '1', + 'a second release exposes the newest internal write from the reacquired value', + ) + }) + test('controlled slice read wins over internal writes', async function (assert) { class TableComponent extends Component { @tracked data: Array = makeData( @@ -550,6 +647,188 @@ module('Integration | external atoms', function (hooks) { ) }) + test('an external atom takes precedence over controlled state and receives table writes', async function (assert) { + const paginationAtom = createAtom({ + pageIndex: 0, + pageSize: 2, + }) + + class TableComponent extends Component { + @tracked pagination: PaginationState = { + pageIndex: 0, + pageSize: 3, + } + + table = useTable(() => ({ + data: makeData(...UNSORTED.map((firstName) => ({ firstName }))), + columns, + features, + state: { pagination: this.pagination }, + atoms: { pagination: paginationAtom }, + })) + + get tablePageSize() { + return this.table.store.state.pagination.pageSize + } + + get atomPageSize() { + return paginationAtom.get().pageSize + } + + get controlledPageSize() { + return this.pagination.pageSize + } + + replaceControlledState = () => { + this.pagination = { pageIndex: 0, pageSize: 1 } + } + + writeThroughTable = () => { + this.table.setPageSize(4) + } + + + } + + await render() + + assert + .dom('[role="status"][aria-label="Table page size"]') + .hasText('2', 'the atom wins over controlled state for the same slice') + assert.dom('[role="status"][aria-label="Atom page size"]').hasText('2') + assert + .dom('[role="status"][aria-label="Controlled page size"]') + .hasText('3') + + await click('[aria-label="Replace controlled pagination"]') + + assert + .dom('[role="status"][aria-label="Controlled page size"]') + .hasText('1', 'the controlled source changed') + assert + .dom('[role="status"][aria-label="Table page size"]') + .hasText('2', 'the table continues reading the atom') + + await click('[aria-label="Set table page size"]') + + assert + .dom('[role="status"][aria-label="Table page size"]') + .hasText('4', 'the table reflects its write through the atom') + assert + .dom('[role="status"][aria-label="Atom page size"]') + .hasText('4', 'the table write was routed to the external atom') + assert + .dom('[role="status"][aria-label="Controlled page size"]') + .hasText('1', 'the lower-precedence controlled state was not mutated') + }) + + test('destroying the owner disconnects both directions of an external atom bridge', async function (assert) { + const paginationAtom = createAtom({ + pageIndex: 0, + pageSize: 2, + }) + let capturedTable: Table | undefined + + class TableComponent extends Component { + table = useTable(this, () => ({ + data: makeData(...UNSORTED.map((firstName) => ({ firstName }))), + columns, + features, + atoms: { pagination: paginationAtom }, + })) + + get pageSize() { + capturedTable = this.table + return this.table.store.state.pagination.pageSize + } + + + } + + class Harness extends Component { + @tracked showTable = true + + removeTable = () => { + this.showTable = false + } + + + } + + await render() + + assert + .dom('[role="status"][aria-label="Owned table page size"]') + .hasText('2') + + paginationAtom.set({ pageIndex: 0, pageSize: 3 }) + await settled() + + assert + .dom('[role="status"][aria-label="Owned table page size"]') + .hasText('3', 'the bridge is live while its owner is mounted') + + await click('[aria-label="Remove owned table"]') + + assert + .dom('[role="status"][aria-label="Owned table page size"]') + .doesNotExist() + + paginationAtom.set({ pageIndex: 0, pageSize: 4 }) + await settled() + + assert.strictEqual( + capturedTable!.atoms.pagination.get().pageSize, + 3, + 'source writes stop reaching the destroyed table', + ) + + capturedTable!.setPageSize(5) + + assert.strictEqual( + paginationAtom.get().pageSize, + 4, + 'table writes stop reaching the source after teardown', + ) + }) + test('atom-backed slices are tracked independently', async function (assert) { const paginationAtom = createAtom({ pageIndex: 0, diff --git a/packages/ember-table/tests/integration/flex-render.test.gts b/packages/ember-table/tests/integration/flex-render.test.gts index 8bee1a45b2..3a989af70b 100644 --- a/packages/ember-table/tests/integration/flex-render.test.gts +++ b/packages/ember-table/tests/integration/flex-render.test.gts @@ -8,8 +8,13 @@ import { useTable, FlexRenderCell, FlexRenderHeader, + aggregationFns, + columnGroupingFeature, + createGroupedRowModel, flexRenderComponent, + rowAggregationFeature, stockFeatures, + tableFeatures, type Row, type Cell, type CellContext, @@ -22,12 +27,52 @@ type Person = { id: string; firstName: string } const defaultData: Array = [{ id: '0', firstName: 'Alice' }] +type GroupedPerson = { + id: string + region: string + team: string + amount: number +} + +const groupingFeatures = tableFeatures({ + aggregationFns, + columnGroupingFeature, + groupedRowModel: createGroupedRowModel(), + rowAggregationFeature, +}) + +const groupingColumns: Array< + ColumnDef +> = [ + { + id: 'region', + accessorKey: 'region', + cell: (context) => `Region ${String(context.getValue())}`, + }, + { + id: 'team', + accessorKey: 'team', + cell: (context) => `Team ${String(context.getValue())}`, + }, + { + id: 'amount', + accessorKey: 'amount', + aggregationFn: 'sum', + cell: (context) => `Amount ${String(context.getValue())}`, + aggregatedCell: (context) => `Total ${String(context.getValue())}`, + }, +] + // Templates can't call bound table methods with `this` context, so expose them // as plain helpers (mirrors the demo-app table templates). const getVisibleCells = ( row: Row, ): Array> => row.getVisibleCells() +const getGroupingCells = ( + row: Row, +): Array> => row.getAllCells() + // --- Cell/header components used by the tests --- class Badge extends Component< @@ -169,6 +214,50 @@ module('Integration | FlexRender', function (hooks) { .hasText('fn value', 'function return value renders') }) + test('renders aggregate cells and suppresses grouping placeholders', async (assert) => { + class TableComponent extends Component { + table = useTable(() => ({ + data: [ + { id: '1', region: 'Europe', team: 'Blue', amount: 1 }, + { id: '2', region: 'Europe', team: 'Green', amount: 2 }, + ], + columns: groupingColumns, + features: groupingFeatures, + initialState: { + grouping: ['region', 'team'], + }, + })) + + get rows() { + return this.table.getRowModel().rows + } + + + } + + await render() + + assert + .dom('[role="status"][aria-label="region"]') + .hasText('Region Europe', 'the active grouping cell uses its cell render') + assert + .dom('[role="status"][aria-label="team"]') + .hasText('', 'the other grouped column renders as a placeholder') + assert + .dom('[role="status"][aria-label="amount"]') + .hasText('Total 3', 'the aggregate column uses aggregatedCell') + }) + // Angular: "should render components" / "Render component with FlexRenderComponent". test('renders a component cell and reacts to arg changes', async (assert) => { class TableComponent extends Component { diff --git a/packages/ember-table/tests/integration/reactivity.test.gts b/packages/ember-table/tests/integration/reactivity.test.gts index 6cf32365d2..ccde88f389 100644 --- a/packages/ember-table/tests/integration/reactivity.test.gts +++ b/packages/ember-table/tests/integration/reactivity.test.gts @@ -18,7 +18,10 @@ import { createColumnHelper, type Row, type Cell, + type ColumnDef, type FlexRenderableSignature, + type OnChangeFn, + type RowSelectionState, } from '#src/index.ts' // --- Shared fixture --- @@ -142,6 +145,156 @@ module('Integration | reactivity', function (hooks) { .containsText('Carol', 'new row content rendered') }) + test('rapid tracked option writes publish only the final snapshot', async function (assert) { + const firstNameColumn: ColumnDef = { + id: 'firstName', + accessorKey: 'firstName', + } + const ageColumn: ColumnDef = { + id: 'age', + accessorKey: 'age', + } + + class TableComponent extends Component { + @tracked data: Array = makeData({ + id: 'initial', + firstName: 'Initial', + age: 30, + }) + @tracked activeColumns: Array> = [ + firstNameColumn, + ] + @tracked enableRowSelection = true + + table = useTable(() => ({ + data: this.data, + columns: this.activeColumns, + features, + getRowId: (row) => row.id, + enableRowSelection: this.enableRowSelection, + })) + + get optionSnapshot() { + const row = this.table.getRowModel().rows[0]! + const snapshot = [ + row.id, + this.table + .getAllLeafColumns() + .map((column) => column.id) + .join(','), + row + .getAllCells() + .map((cell) => String(cell.getValue())) + .join(','), + String(row.getCanSelect()), + ].join('|') + + assert.step(snapshot) + return snapshot + } + + updateRapidly = () => { + this.data = makeData({ + id: 'intermediate', + firstName: 'Intermediate', + age: 40, + }) + this.activeColumns = [firstNameColumn, ageColumn] + this.enableRowSelection = false + this.data = makeData({ + id: 'final', + firstName: 'Final', + age: 42, + }) + this.activeColumns = [ageColumn] + } + + + } + + await render() + + assert.verifySteps( + ['initial|firstName|Initial|true'], + 'the initial options are read as one consistent snapshot', + ) + + await click('[aria-label="Update options rapidly"]') + + assert.verifySteps( + ['final|age|42|false'], + 'the rerender observes only the final tracked option values', + ) + assert + .dom('[role="status"][aria-label="Option snapshot"]') + .hasText('final|age|42|false') + }) + + test('table APIs use the latest tracked option callback', async function (assert) { + class TableComponent extends Component { + @tracked selectionHandler: OnChangeFn = () => { + assert.step('first handler') + } + + table = useTable(() => ({ + data: makeData({ id: '1', firstName: 'Alice' }), + columns, + features, + getRowId: (row) => row.id, + onRowSelectionChange: this.selectionHandler, + })) + + selectAll = () => { + this.table.toggleAllRowsSelected(true) + } + + replaceHandler = () => { + this.selectionHandler = () => { + assert.step('second handler') + } + } + + + } + + await render() + + await click('[aria-label="Select all rows"]') + assert.verifySteps( + ['first handler'], + 'the table initially invokes the first callback', + ) + + await click('[aria-label="Replace selection handler"]') + await click('[aria-label="Select all rows"]') + + assert.verifySteps( + ['second handler'], + 'the same table instance invokes the latest tracked callback', + ) + }) + test('internal state updates propagate to the DOM (pagination + selection)', async function (assert) { class TableComponent extends Component { @tracked data: Array = makeData( diff --git a/packages/lit-table/package.json b/packages/lit-table/package.json index 626ba60988..2a1dd97b22 100644 --- a/packages/lit-table/package.json +++ b/packages/lit-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" @@ -51,6 +53,7 @@ }, "devDependencies": { "@lit/context": "^1.1.6", + "@testing-library/dom": "^10.4.1", "lit": "^3.3.3" }, "peerDependencies": { diff --git a/packages/lit-table/src/TableController.ts b/packages/lit-table/src/TableController.ts index 481e72b166..a3211e74f6 100644 --- a/packages/lit-table/src/TableController.ts +++ b/packages/lit-table/src/TableController.ts @@ -250,6 +250,9 @@ export class TableController< hostConnected() { this._setupSubscriptions() + if (this._table) { + this.host.requestUpdate() + } } hostUpdated() { diff --git a/packages/lit-table/src/createTableHook.ts b/packages/lit-table/src/createTableHook.ts index 4b52ded673..cd8c67313d 100644 --- a/packages/lit-table/src/createTableHook.ts +++ b/packages/lit-table/src/createTableHook.ts @@ -3,8 +3,9 @@ import { ContextConsumer, ContextProvider, createContext } from '@lit/context' import { FlexRender, flexRender } from './flexRender' import { TableController } from './TableController' import type { Context } from '@lit/context' +import type { LitRenderable } from './flexRender' import type { LitTable } from './TableController' -import type { ReactiveControllerHost, TemplateResult } from 'lit' +import type { ReactiveControllerHost } from 'lit' import type { AccessorFn, AccessorFnColumnDef, @@ -53,7 +54,7 @@ export type AppCellContext< > = { cell: Cell & BoundComponents & { - FlexRender: () => TemplateResult | string | null + FlexRender: () => LitRenderable } column: Column getValue: CellContext['getValue'] @@ -75,7 +76,7 @@ export type AppHeaderContext< column: Column header: Header & BoundComponents & { - FlexRender: () => TemplateResult | string | null + FlexRender: () => LitRenderable } table: Table } @@ -306,10 +307,10 @@ export type AppLitTable< renderFn: ( cell: Cell & BoundComponents & { - FlexRender: () => TemplateResult | string | null + FlexRender: () => LitRenderable }, - ) => TemplateResult | string, - ) => TemplateResult | string + ) => LitRenderable, + ) => LitRenderable /** * Wraps a header and provides header context with pre-bound headerComponents. * @example @@ -322,10 +323,10 @@ export type AppLitTable< renderFn: ( header: Header & BoundComponents & { - FlexRender: () => TemplateResult | string | null + FlexRender: () => LitRenderable }, - ) => TemplateResult | string, - ) => TemplateResult | string + ) => LitRenderable, + ) => LitRenderable /** * Wraps a footer and provides header context with pre-bound headerComponents. * @example @@ -338,10 +339,10 @@ export type AppLitTable< renderFn: ( header: Header & BoundComponents & { - FlexRender: () => TemplateResult | string | null + FlexRender: () => LitRenderable }, - ) => TemplateResult | string, - ) => TemplateResult | string + ) => LitRenderable, + ) => LitRenderable /** * Convenience FlexRender function attached to the table instance. * Renders cell, header, or footer content from column definitions. @@ -739,12 +740,11 @@ export function createTableHook< renderFn: ( cell: Cell & BoundComponents & { - FlexRender: () => TemplateResult | string | null + FlexRender: () => LitRenderable }, - ) => TemplateResult | string, - ): TemplateResult | string { - const cellFlexRender = () => - flexRender(cell.column.columnDef.cell, cell.getContext()) + ) => LitRenderable, + ): LitRenderable { + const cellFlexRender = () => FlexRender({ cell }) // Bind each cell component so it receives the cell as its first argument. // This allows column defs to call `cell.TextCell()` which internally @@ -759,7 +759,7 @@ export function createTableHook< ...boundCellComponents, }) as Cell & BoundComponents & { - FlexRender: () => TemplateResult | string | null + FlexRender: () => LitRenderable } return renderFn(extendedCell) @@ -771,10 +771,10 @@ export function createTableHook< renderFn: ( header: Header & BoundComponents & { - FlexRender: () => TemplateResult | string | null + FlexRender: () => LitRenderable }, - ) => TemplateResult | string, - ): TemplateResult | string { + ) => LitRenderable, + ): LitRenderable { const headerFlexRender = () => flexRender(header.column.columnDef.header, header.getContext()) @@ -789,7 +789,7 @@ export function createTableHook< ...boundHeaderComponents, }) as Header & BoundComponents & { - FlexRender: () => TemplateResult | string | null + FlexRender: () => LitRenderable } return renderFn(extendedHeader) @@ -801,10 +801,10 @@ export function createTableHook< renderFn: ( header: Header & BoundComponents & { - FlexRender: () => TemplateResult | string | null + FlexRender: () => LitRenderable }, - ) => TemplateResult | string, - ): TemplateResult | string { + ) => LitRenderable, + ): LitRenderable { const footerFlexRender = () => flexRender(header.column.columnDef.footer, header.getContext()) @@ -819,7 +819,7 @@ export function createTableHook< ...boundFooterComponents, }) as Header & BoundComponents & { - FlexRender: () => TemplateResult | string | null + FlexRender: () => LitRenderable } return renderFn(extendedHeader) diff --git a/packages/lit-table/src/flexRender.ts b/packages/lit-table/src/flexRender.ts index e9c43b1c68..35b8b5bec8 100644 --- a/packages/lit-table/src/flexRender.ts +++ b/packages/lit-table/src/flexRender.ts @@ -5,7 +5,22 @@ import type { RowData, TableFeatures, } from '@tanstack/table-core' -import type { TemplateResult } from 'lit' +import type { TemplateResult, noChange, nothing } from 'lit' +import type { DirectiveResult } from 'lit/directive.js' + +export type LitRenderable = + | TemplateResult + | DirectiveResult + | Node + | string + | number + | bigint + | boolean + | null + | undefined + | typeof nothing + | typeof noChange + | Iterable /** * Renders a Lit table template value with the provided context props. @@ -20,14 +35,10 @@ import type { TemplateResult } from 'lit' * ``` */ export function flexRender( - Comp: - | ((props: TProps) => TemplateResult | string) - | string - | TemplateResult - | undefined, + Comp: ((props: TProps) => LitRenderable) | LitRenderable, props: TProps, -): TemplateResult | string | null { - if (!Comp) return null +): LitRenderable { + if (Comp === null || Comp === undefined) return null if (typeof Comp === 'function') { return Comp(props) @@ -91,11 +102,30 @@ export function FlexRender< TFeatures extends TableFeatures, TData extends RowData, TValue extends CellData = CellData, ->( - props: FlexRenderProps, -): TemplateResult | string | null { +>(props: FlexRenderProps): LitRenderable { if ('cell' in props && props.cell) { - return flexRender(props.cell.column.columnDef.cell, props.cell.getContext()) + const cell = props.cell + const columnDef = cell.column.columnDef + const groupingCell = cell as typeof cell & { + getIsAggregated?: () => boolean + getIsPlaceholder?: () => boolean + } + const groupingColumnDef = columnDef as typeof columnDef & { + aggregatedCell?: typeof columnDef.cell + } + + if (groupingCell.getIsAggregated?.()) { + return flexRender( + groupingColumnDef.aggregatedCell ?? columnDef.cell, + cell.getContext(), + ) + } + + if (groupingCell.getIsPlaceholder?.()) { + return null + } + + return flexRender(columnDef.cell, cell.getContext()) } if ('header' in props && props.header) { diff --git a/packages/lit-table/src/subscribe-directive.ts b/packages/lit-table/src/subscribe-directive.ts index e6db99371e..70a649a479 100644 --- a/packages/lit-table/src/subscribe-directive.ts +++ b/packages/lit-table/src/subscribe-directive.ts @@ -154,6 +154,9 @@ export class SubscribeDirective extends AsyncDirective { /** Restores the controller subscription when the directive is re-attached to the DOM. */ reconnected() { this.controller?.hostUpdate() + if (this.resolvedTemplate && this.controller) { + this.setValue(this.resolvedTemplate(this.controller.value)) + } } /** diff --git a/packages/lit-table/tests/unit/adapterLifecycle.test.ts b/packages/lit-table/tests/unit/adapterLifecycle.test.ts new file mode 100644 index 0000000000..8dd224f30c --- /dev/null +++ b/packages/lit-table/tests/unit/adapterLifecycle.test.ts @@ -0,0 +1,377 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, test, vi } from 'vitest' +import { fireEvent, screen } from '@testing-library/dom' +import { createAtom } from '@tanstack/lit-store' +import { stockFeatures } from '@tanstack/table-core' +import { LitElement, html } from 'lit' +import { TableController } from '../../src/TableController' +import type { + ColumnDef, + LitTable, + OnChangeFn, + RowSelectionState, +} from '../../src' + +type Data = { id: string; title: string } + +const idColumn: ColumnDef = { + id: 'id', + accessorKey: 'id', +} +const titleColumn: ColumnDef = { + id: 'title', + accessorKey: 'title', +} + +let elementId = 0 + +function mount( + elementClass: CustomElementConstructor, +): TElement { + const tagName = `lit-table-adapter-test-${elementId++}` + customElements.define(tagName, elementClass) + const element = document.createElement(tagName) as TElement + document.body.append(element) + return element +} + +function outputText(name: string) { + return screen.getByRole('status', { name }).textContent.trim() +} + +afterEach(() => { + document.body.replaceChildren() + vi.restoreAllMocks() +}) + +describe('TableController lifecycle and option ownership', () => { + test('disconnecting the host stops root reactions to later state changes', async () => { + const sourceAtom = createAtom({}) + const rootSelectorCaptor = vi.fn<(state: RowSelectionState) => void>() + + class LifecycleTable extends LitElement { + private controller = new TableController(this) + table?: LitTable + + createRenderRoot() { + return this + } + + protected render() { + const table = this.controller.table( + { + data: [{ id: '1', title: 'First' }], + columns: [idColumn, titleColumn], + features: stockFeatures, + getRowId: (row) => row.id, + atoms: { + rowSelection: sourceAtom, + }, + }, + (state) => { + rootSelectorCaptor(state.rowSelection) + return state.rowSelection + }, + ) + this.table = table + + return html` + + ${JSON.stringify(table.state)} + + ` + } + } + + const element = mount(LifecycleTable) + await element.updateComplete + + expect(rootSelectorCaptor.mock.lastCall?.[0]).toEqual({}) + + sourceAtom.set({ 1: true }) + await element.updateComplete + + expect(outputText('Lifecycle selection')).toBe('{"1":true}') + expect(rootSelectorCaptor.mock.lastCall?.[0]).toEqual({ 1: true }) + + element.remove() + + const callsAfterDisconnect = rootSelectorCaptor.mock.calls.length + + sourceAtom.set({ 2: true }) + element.table?.setPageSize(25) + + expect(rootSelectorCaptor).toHaveBeenCalledTimes(callsAfterDisconnect) + + document.body.append(element) + await element.updateComplete + + expect(outputText('Lifecycle selection')).toBe('{"2":true}') + expect(rootSelectorCaptor.mock.lastCall?.[0]).toEqual({ 2: true }) + }) + + test('controlled state can release and reacquire ownership of a slice', async () => { + class OwnershipTable extends LitElement { + private controller = new TableController(this) + private controlledState: { rowSelection?: RowSelectionState } = { + rowSelection: { 1: true }, + } + table?: LitTable + + createRenderRoot() { + return this + } + + setControlledState(state: { rowSelection?: RowSelectionState }) { + this.controlledState = state + this.requestUpdate() + } + + protected render() { + const table = this.controller.table( + { + data: [ + { id: '1', title: 'First' }, + { id: '2', title: 'Second' }, + ], + columns: [idColumn, titleColumn], + features: stockFeatures, + getRowId: (row) => row.id, + state: this.controlledState, + }, + (state) => state.rowSelection, + ) + this.table = table + + return html` + + ${JSON.stringify(table.state)} + + ` + } + } + + const element = mount(OwnershipTable) + await element.updateComplete + + expect(outputText('Owned selection')).toBe('{"1":true}') + + element.setControlledState({}) + await element.updateComplete + + expect(outputText('Owned selection')).toBe('{"1":true}') + + element.table!.setRowSelection({ 2: true }) + await element.updateComplete + + expect(outputText('Owned selection')).toBe('{"2":true}') + + element.setControlledState({ rowSelection: { 1: true, 2: true } }) + await element.updateComplete + + expect(outputText('Owned selection')).toBe('{"1":true,"2":true}') + + element.table!.setRowSelection({}) + await element.updateComplete + + expect(outputText('Owned selection')).toBe('{"1":true,"2":true}') + + element.setControlledState({}) + await element.updateComplete + + expect(outputText('Owned selection')).toBe('{}') + }) + + test('an external atom takes precedence over controlled state and receives table writes', async () => { + const externalAtom = createAtom({ 2: true }) + + class ExternalAtomTable extends LitElement { + private controller = new TableController(this) + private controlledSelection: RowSelectionState = { 1: true } + table?: LitTable + + createRenderRoot() { + return this + } + + setControlledSelection(selection: RowSelectionState) { + this.controlledSelection = selection + this.requestUpdate() + } + + protected render() { + const table = this.controller.table( + { + data: [ + { id: '1', title: 'First' }, + { id: '2', title: 'Second' }, + ], + columns: [idColumn, titleColumn], + features: stockFeatures, + getRowId: (row) => row.id, + state: { + rowSelection: this.controlledSelection, + }, + atoms: { + rowSelection: externalAtom, + }, + }, + (state) => state.rowSelection, + ) + this.table = table + + return html` + + ${JSON.stringify(table.state)} + + + ` + } + } + + const element = mount(ExternalAtomTable) + await element.updateComplete + + expect(outputText('External selection')).toBe('{"2":true}') + + element.setControlledSelection({ 1: true, 2: true }) + await element.updateComplete + + expect(outputText('External selection')).toBe('{"2":true}') + + externalAtom.set({ 1: true, 2: true }) + await element.updateComplete + + expect(outputText('External selection')).toBe('{"1":true,"2":true}') + + fireEvent.click( + screen.getByRole('button', { name: 'Deselect external row' }), + ) + await element.updateComplete + + expect(externalAtom.get()).toEqual({ 2: true }) + expect(outputText('External selection')).toBe('{"2":true}') + }) + + test('coalesces dynamic data, columns, and callbacks into the next render', async () => { + const firstSelectionHandler = vi.fn>() + const secondSelectionHandler = vi.fn>() + const renderCaptor = + vi.fn< + (snapshot: { + canSelect: boolean + columnIds: Array + values: Array + }) => void + >() + + class DynamicOptionsTable extends LitElement { + private controller = new TableController(this) + private data: Array = [{ id: '1', title: 'Initial' }] + private columns: Array> = [idColumn] + private enableRowSelection = true + private onRowSelectionChange: OnChangeFn = + firstSelectionHandler + table?: LitTable + + createRenderRoot() { + return this + } + + setOptions(options: { + columns: Array> + data: Array + enableRowSelection: boolean + onRowSelectionChange: OnChangeFn + }) { + this.data = options.data + this.columns = options.columns + this.enableRowSelection = options.enableRowSelection + this.onRowSelectionChange = options.onRowSelectionChange + this.requestUpdate() + } + + protected render() { + const table = this.controller.table( + { + data: this.data, + columns: this.columns, + features: stockFeatures, + enableRowSelection: this.enableRowSelection, + getRowId: (row) => row.id, + onRowSelectionChange: this.onRowSelectionChange, + }, + () => null, + ) + this.table = table + const row = table.getRowModel().rows[0]! + const snapshot = { + canSelect: row.getCanSelect(), + columnIds: table.getAllLeafColumns().map((column) => column.id), + values: row.getAllCells().map((cell) => cell.getValue()), + } + renderCaptor(snapshot) + + return html` + + ${JSON.stringify(snapshot)} + + ` + } + } + + const element = mount(DynamicOptionsTable) + await element.updateComplete + + expect(element.table?.subscribe).toEqual(expect.any(Function)) + expect(element.table?.FlexRender).toEqual(expect.any(Function)) + expect('state' in element.table!).toBe(true) + + element.table!.toggleAllRowsSelected(true) + + expect(firstSelectionHandler).toHaveBeenCalledOnce() + expect(secondSelectionHandler).not.toHaveBeenCalled() + + element.setOptions({ + data: [{ id: '2', title: 'Intermediate' }], + columns: [idColumn, titleColumn], + enableRowSelection: false, + onRowSelectionChange: secondSelectionHandler, + }) + element.setOptions({ + data: [{ id: '3', title: 'Final' }], + columns: [titleColumn], + enableRowSelection: false, + onRowSelectionChange: secondSelectionHandler, + }) + await element.updateComplete + + expect(renderCaptor.mock.calls).toEqual([ + [ + { + canSelect: true, + columnIds: ['id'], + values: ['1'], + }, + ], + [ + { + canSelect: false, + columnIds: ['title'], + values: ['Final'], + }, + ], + ]) + expect(outputText('Dynamic options')).toBe( + '{"canSelect":false,"columnIds":["title"],"values":["Final"]}', + ) + + element.table!.toggleAllRowsSelected(false) + + expect(firstSelectionHandler).toHaveBeenCalledOnce() + expect(secondSelectionHandler).toHaveBeenCalledOnce() + }) +}) diff --git a/packages/lit-table/tests/unit/defaultReactivity.test.ts b/packages/lit-table/tests/unit/defaultReactivity.test.ts index 6ff9729a05..692db675e5 100644 --- a/packages/lit-table/tests/unit/defaultReactivity.test.ts +++ b/packages/lit-table/tests/unit/defaultReactivity.test.ts @@ -5,7 +5,9 @@ describe('TableController', () => { test('uses default reactivity when constructing a table', () => { const host = { addController: () => {}, + removeController: () => {}, requestUpdate: () => {}, + updateComplete: Promise.resolve(true), } const controller = new TableController(host) diff --git a/packages/lit-table/tests/unit/flexRender.test.ts b/packages/lit-table/tests/unit/flexRender.test.ts new file mode 100644 index 0000000000..ff4e4433f8 --- /dev/null +++ b/packages/lit-table/tests/unit/flexRender.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test, vi } from 'vitest' +import { html, nothing } from 'lit' +import { flexRender } from '../../src/flexRender' + +describe('flexRender', () => { + test('handles empty, static, template, and callback renderers', () => { + const props = { value: 'Ada' } + const callback = vi.fn( + (context: typeof props) => html`${context.value}`, + ) + const existingTemplate = html`Existing` + + expect(flexRender(undefined, props)).toBeNull() + expect(flexRender(null, props)).toBeNull() + expect(flexRender('', props)).toBe('') + expect(flexRender(0, props)).toBe(0) + expect(flexRender(false, props)).toBe(false) + expect(flexRender(nothing, props)).toBe(nothing) + expect(flexRender(['Ada', 0], props)).toEqual(['Ada', 0]) + expect(flexRender('Plain text', props)).toBe('Plain text') + expect(flexRender(existingTemplate, props)).toBe(existingTemplate) + expect(flexRender(callback, props)).toEqual( + html`${props.value}`, + ) + expect(callback).toHaveBeenCalledOnce() + expect(callback).toHaveBeenCalledWith(props) + expect(flexRender(() => 0, props)).toBe(0) + }) +}) diff --git a/packages/lit-table/tests/unit/rendering.test.ts b/packages/lit-table/tests/unit/rendering.test.ts new file mode 100644 index 0000000000..b5063ae163 --- /dev/null +++ b/packages/lit-table/tests/unit/rendering.test.ts @@ -0,0 +1,460 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, test, vi } from 'vitest' +import { fireEvent, screen, waitFor } from '@testing-library/dom' +import { createAtom } from '@tanstack/lit-store' +import { stockFeatures } from '@tanstack/table-core' +import { LitElement, html } from 'lit' +import { createTableHook } from '../../src/createTableHook' +import { FlexRender } from '../../src/flexRender' +import { TableController } from '../../src/TableController' +import { subscribe } from '../../src/subscribe-directive' +import type { Cell, ColumnDef, Header, RowSelectionState } from '../../src' + +type Data = { id: string; name: string } + +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}`, + }, +] + +let elementId = 0 + +function mount( + elementClass: CustomElementConstructor, +): TElement { + const tagName = `lit-table-render-test-${elementId++}` + customElements.define(tagName, elementClass) + const element = document.createElement(tagName) as TElement + document.body.append(element) + return element +} + +function outputText(name: string) { + return screen.getByRole('status', { name }).textContent.trim() +} + +afterEach(() => { + document.body.replaceChildren() + vi.restoreAllMocks() +}) + +describe('FlexRender', () => { + test('renders normal, aggregate, placeholder, header, and footer templates reactively', async () => { + class FlexRenderTable extends LitElement { + private controller = new TableController(this) + private mode: 'normal' | 'aggregate' | 'placeholder' = 'normal' + private cell?: Cell + private header?: Header + private footer?: Header + + createRenderRoot() { + return this + } + + setMode(mode: 'normal' | 'aggregate' | 'placeholder') { + this.mode = mode + this.requestUpdate() + } + + protected render() { + const table = this.controller.table({ + data: [{ id: '1', name: 'Ada' }], + columns, + features: stockFeatures, + getRowId: (row) => row.id, + }) + + if (!this.cell) { + this.cell = table.getRow('1').getAllCells()[0] as Cell< + typeof stockFeatures, + Data, + string + > + this.header = table.getHeaderGroups()[0]!.headers[0] as Header< + typeof stockFeatures, + Data, + string + > + this.footer = table.getFooterGroups()[0]!.headers[0] as Header< + typeof stockFeatures, + Data, + string + > + vi.spyOn(this.cell, 'getIsAggregated').mockImplementation( + () => this.mode === 'aggregate', + ) + vi.spyOn(this.cell, 'getIsPlaceholder').mockImplementation( + () => this.mode === 'placeholder', + ) + } + + return html` + + ${FlexRender({ cell: this.cell })} + + + ${FlexRender({ header: this.header! })} + + + ${FlexRender({ footer: this.footer! })} + + ` + } + } + + const element = mount(FlexRenderTable) + await element.updateComplete + + expect(outputText('Rendered cell')).toBe('cell:Ada') + expect(outputText('Rendered header')).toBe('header:name') + expect(outputText('Rendered footer')).toBe('footer:name') + + element.setMode('aggregate') + await element.updateComplete + + expect(outputText('Rendered cell')).toBe('aggregate:Ada') + + element.setMode('placeholder') + await element.updateComplete + + expect(outputText('Rendered cell')).toBe('') + + element.setMode('normal') + await element.updateComplete + + expect(outputText('Rendered cell')).toBe('cell:Ada') + }) +}) + +describe('table.subscribe', () => { + test('updates both source overloads without rerendering an opted-out host', async () => { + const hostRenderCaptor = vi.fn() + + class SubscribeTable extends LitElement { + private controller = new TableController(this) + + createRenderRoot() { + return this + } + + protected render() { + hostRenderCaptor() + const table = this.controller.table( + { + data: [{ id: '1', name: 'Ada' }], + columns, + features: stockFeatures, + getRowId: (row) => row.id, + }, + () => ({}), + ) + + return html` + ${table.subscribe( + table.atoms.rowSelection, + (selection) => html` + + ${JSON.stringify(selection)} + + `, + )} + ${table.subscribe( + table.store, + (state) => Boolean(state.rowSelection['1']), + (selected) => html` + + ${String(selected)} + + `, + )} + + ` + } + } + + const element = mount(SubscribeTable) + await element.updateComplete + + expect(outputText('Subscribed atom selection')).toBe('{}') + expect(outputText('Subscribed selected row')).toBe('false') + + fireEvent.click( + screen.getByRole('button', { name: 'Select subscribed row' }), + ) + + await waitFor(() => { + expect(outputText('Subscribed atom selection')).toBe('{"1":true}') + expect(outputText('Subscribed selected row')).toBe('true') + }) + expect(hostRenderCaptor).toHaveBeenCalledOnce() + }) + + test('disconnecting a directive unsubscribes its source', async () => { + const sourceAtom = createAtom({}) + const subscribeSpy = vi.spyOn(sourceAtom, 'subscribe') + const templateCaptor = vi.fn<(selection: RowSelectionState) => void>() + + class SubscribeLifecycle extends LitElement { + createRenderRoot() { + return this + } + + protected render() { + return html` + ${subscribe(sourceAtom, (selection) => { + templateCaptor(selection) + return html` + + ${JSON.stringify(selection)} + + ` + })} + ` + } + } + + const element = mount(SubscribeLifecycle) + await element.updateComplete + + expect(subscribeSpy).toHaveBeenCalledOnce() + + sourceAtom.set({ 1: true }) + await waitFor(() => { + expect(outputText('Directive lifecycle selection')).toBe('{"1":true}') + }) + + element.remove() + + const callsAfterDisconnect = templateCaptor.mock.calls.length + sourceAtom.set({ 2: true }) + + expect(templateCaptor).toHaveBeenCalledTimes(callsAfterDisconnect) + + document.body.append(element) + await element.updateComplete + + expect(subscribeSpy).toHaveBeenCalledTimes(2) + await waitFor(() => { + expect(outputText('Directive lifecycle selection')).toBe('{"2":true}') + }) + + sourceAtom.set({ 3: true }) + await waitFor(() => { + expect(outputText('Directive lifecycle selection')).toBe('{"3":true}') + }) + }) +}) + +describe('createTableHook runtime', () => { + test('binds defaults, registered renderers, wrappers, and table context', async () => { + const tableBadge = vi.fn(() => html`table-badge`) + const cellBadge = vi.fn( + (cell: Cell) => + html`cell-badge:${cell.id}`, + ) + const headerBadge = vi.fn( + (header: Header) => + html`header-badge:${header.id}`, + ) + const hook = createTableHook({ + features: stockFeatures, + enableRowSelection: false, + getRowId: (row: Data) => `row-${row.id}`, + tableComponents: { tableBadge }, + cellComponents: { cellBadge }, + headerComponents: { headerBadge }, + }) + const columnHelper = hook.createAppColumnHelper() + const appColumns = columnHelper.columns([ + columnHelper.accessor('name', { + header: ({ column }) => `header:${column.id}`, + cell: ({ getValue }) => `cell:${getValue()}`, + footer: ({ column }) => `footer:${column.id}`, + }), + ]) + const tableContextCaptor = vi.fn<(table: unknown) => void>() + + class TableContextConsumer extends LitElement { + private tableConsumer = hook.useTableContext(this) + + createRenderRoot() { + return this + } + + protected render() { + const table = this.tableConsumer.value + tableContextCaptor(table) + + return html` + + ${String(Boolean(table))} + + ` + } + } + customElements.define( + 'lit-table-context-consumer-test', + TableContextConsumer, + ) + + class AppTable extends LitElement { + private appTable = hook.useAppTable(this, { + data: [{ id: '1', name: 'Ada' }], + columns: appColumns, + enableRowSelection: true, + }) + table?: ReturnType<(typeof this.appTable)['table']> + + createRenderRoot() { + return this + } + + protected render() { + const table = this.appTable.table() + this.table = table + 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]! + + return html` + + ${String(row.getCanSelect())} + + ${table.tableBadge()} + ${table.AppCell( + cell, + (appCell) => html` + ${appCell.cellBadge()} + ${appCell.FlexRender()} + `, + )} + ${table.AppHeader( + header, + (appHeader) => html` + ${appHeader.headerBadge()} + + ${appHeader.FlexRender()} + + `, + )} + ${table.AppFooter( + footer, + (appFooter) => html` + + ${appFooter.FlexRender()} + + `, + )} + + ` + } + } + + const element = mount(AppTable) + await element.updateComplete + await waitFor(() => { + expect(outputText('Table context available')).toBe('true') + }) + + const table = element.table! + const cell = table.getRowModel().rows[0]!.getAllCells()[0]! + const header = table.getHeaderGroups()[0]!.headers[0]! + + expect(hook.appFeatures).toBe(stockFeatures) + expect(table.FlexRender).toBe(FlexRender) + expect(table.AppCell).toEqual(expect.any(Function)) + expect(table.AppHeader).toEqual(expect.any(Function)) + expect(table.AppFooter).toEqual(expect.any(Function)) + expect(outputText('Row can be selected')).toBe('true') + expect(outputText('Bound cell')).toBe('cell:Ada') + expect(outputText('Bound header')).toBe('header:name') + expect(outputText('Bound footer')).toBe('footer:name') + expect(screen.getByText(`cell-badge:${cell.id}`)).toBeTruthy() + expect(screen.getByText(`header-badge:${header.id}`)).toBeTruthy() + expect(screen.getByText('table-badge')).toBeTruthy() + expect(cellBadge).toHaveBeenCalledWith(cell) + expect(headerBadge).toHaveBeenCalledWith(header) + expect(tableContextCaptor).toHaveBeenCalledWith(table) + }) + + test('bound cell FlexRender preserves aggregate and placeholder modes', async () => { + const hook = createTableHook({ + features: stockFeatures, + }) + const columnHelper = hook.createAppColumnHelper() + const appColumns = columnHelper.columns([ + columnHelper.accessor('name', { + cell: ({ getValue }) => `cell:${getValue()}`, + aggregatedCell: ({ getValue }) => `aggregate:${getValue()}`, + }), + ]) + + class BoundCellTable extends LitElement { + private appTable = hook.useAppTable(this, { + data: [{ id: '1', name: 'Ada' }], + columns: appColumns, + getRowId: (row) => row.id, + }) + private mode: 'aggregate' | 'placeholder' | 'normal' = 'aggregate' + private cell?: Cell + + createRenderRoot() { + return this + } + + setMode(mode: 'aggregate' | 'placeholder' | 'normal') { + this.mode = mode + this.requestUpdate() + } + + protected render() { + const table = this.appTable.table() + if (!this.cell) { + this.cell = table.getRow('1').getAllCells()[0] as Cell< + typeof stockFeatures, + Data, + string + > + vi.spyOn(this.cell, 'getIsAggregated').mockImplementation( + () => this.mode === 'aggregate', + ) + vi.spyOn(this.cell, 'getIsPlaceholder').mockImplementation( + () => this.mode === 'placeholder', + ) + } + + return table.AppCell( + this.cell, + (cell) => html` + ${cell.FlexRender()} + `, + ) + } + } + + const element = mount(BoundCellTable) + await element.updateComplete + + expect(outputText('Bound cell mode')).toBe('aggregate:Ada') + + element.setMode('placeholder') + await element.updateComplete + + expect(outputText('Bound cell mode')).toBe('') + + element.setMode('normal') + await element.updateComplete + + expect(outputText('Bound cell mode')).toBe('cell:Ada') + }) +}) diff --git a/packages/lit-table/tests/unit/selectorGate.test.ts b/packages/lit-table/tests/unit/selectorGate.test.ts index 535a20892f..4119961b12 100644 --- a/packages/lit-table/tests/unit/selectorGate.test.ts +++ b/packages/lit-table/tests/unit/selectorGate.test.ts @@ -6,9 +6,11 @@ function createHost() { const host = { updateCount: 0, addController: () => {}, + removeController: () => {}, requestUpdate: () => { host.updateCount++ }, + updateComplete: Promise.resolve(true), } return host } diff --git a/packages/lit-table/tsconfig.json b/packages/lit-table/tsconfig.json index eb63835950..d69d19f8e1 100644 --- a/packages/lit-table/tsconfig.json +++ b/packages/lit-table/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "../../tsconfig.json", - "include": ["src", "eslint.config.js", "vite.config.ts"] + "include": ["src", "tests", "eslint.config.js", "vite.config.ts"] } diff --git a/packages/preact-table/package.json b/packages/preact-table/package.json index 4e7b793fe7..0ba83eb514 100644 --- a/packages/preact-table/package.json +++ b/packages/preact-table/package.json @@ -53,7 +53,9 @@ }, "devDependencies": { "@preact/preset-vite": "^2.10.5", - "preact": "^10.29.2" + "@testing-library/preact": "^3.2.4", + "preact": "^10.29.2", + "preact-render-to-string": "^6.7.0" }, "peerDependencies": { "preact": ">=10" diff --git a/packages/preact-table/src/FlexRender.tsx b/packages/preact-table/src/FlexRender.tsx index 07c18569a3..7d1e512903 100644 --- a/packages/preact-table/src/FlexRender.tsx +++ b/packages/preact-table/src/FlexRender.tsx @@ -33,9 +33,12 @@ function isExoticComponent(component: any) { return ( typeof component === 'object' && typeof component.$$typeof === 'symbol' && - ['preact.memo', 'preact.forward_ref'].includes( - component.$$typeof.description, - ) + [ + 'preact.memo', + 'preact.forward_ref', + 'react.memo', + 'react.forward_ref', + ].includes(component.$$typeof.description) ) } @@ -47,11 +50,11 @@ export function flexRender( Comp: Renderable | null, props: TProps, ): ComponentChild | Element | null { - return !Comp ? null : isPreactComponent(Comp) ? ( - - ) : ( - Comp - ) + if (Comp === null || Comp === undefined) { + return null + } + + return isPreactComponent(Comp) ? : Comp } /** diff --git a/packages/preact-table/tests/unit/adapterReactivity.test.tsx b/packages/preact-table/tests/unit/adapterReactivity.test.tsx new file mode 100644 index 0000000000..74e92cb42a --- /dev/null +++ b/packages/preact-table/tests/unit/adapterReactivity.test.tsx @@ -0,0 +1,609 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from '@testing-library/preact' +import { useLayoutEffect, useState } from 'preact/hooks' +import { act } from 'preact/test-utils' +import { + createPaginatedRowModel, + stockFeatures, + tableFeatures, +} from '@tanstack/table-core' +import { createAtom } from '@tanstack/preact-store' +import { afterEach, describe, expect, test, vi } from 'vitest' +import { useTable } from '../../src' +import type { + ColumnDef, + OnChangeFn, + PaginationState, + RowSelectionState, +} from '@tanstack/table-core' +import type { PreactTable } 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(), + }, +] + +function text(name: string) { + return screen.getByRole('status', { name }).textContent +} + +function click(name: string) { + fireEvent.click(screen.getByRole('button', { name })) +} + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +// Adapter contract only: Preact/store ownership, subscriptions, lifecycle, and +// option refreshes. Row-model algorithms remain covered by table-core. +describe('Preact adapter reactivity and lifecycle', () => { + test('exposes Preact 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] = 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) => ( + + {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('isolates Subscribe render cycles to each selected dependency', () => { + const ownerRenderCaptor = vi.fn() + const selectedRowRenderCaptor = vi.fn<(selected: boolean) => void>() + const selectionAtomRenderCaptor = + vi.fn<(state: RowSelectionState) => void>() + const pageSizeRenderCaptor = vi.fn<(pageSize: number) => void>() + + function TableHarness() { + ownerRenderCaptor() + const table = useTable( + { + data: [{ id: '1', title: 'Title' }], + features: stockFeatures, + columns, + getRowId: (row) => row.id, + }, + () => null, + ) + + return ( + <> + Boolean(state.rowSelection['1'])} + > + {(selected) => { + selectedRowRenderCaptor(selected) + return ( + + {String(selected)} + + ) + }} + + + {(selection) => { + selectionAtomRenderCaptor(selection) + return ( + + {JSON.stringify(selection)} + + ) + }} + + state.pagination.pageSize}> + {(pageSize) => { + pageSizeRenderCaptor(pageSize) + return ( + {pageSize} + ) + }} + + + + + + ) + } + + render() + + expect(ownerRenderCaptor).toHaveBeenCalledTimes(1) + expect(selectedRowRenderCaptor.mock.calls).toEqual([[false]]) + expect(selectionAtomRenderCaptor.mock.calls).toEqual([[{}]]) + expect(pageSizeRenderCaptor.mock.calls).toEqual([[10]]) + + act(() => { + click('Select first row') + }) + + expect(text('Selected row value')).toBe('true') + expect(text('Selection atom value')).toBe('{"1":true}') + expect(text('Subscribed page size')).toBe('10') + expect(ownerRenderCaptor).toHaveBeenCalledTimes(1) + expect(selectedRowRenderCaptor.mock.calls).toEqual([[false], [true]]) + expect(selectionAtomRenderCaptor.mock.calls).toEqual([[{}], [{ 1: true }]]) + expect(pageSizeRenderCaptor.mock.calls).toEqual([[10]]) + + act(() => { + click('Select second row too') + }) + + expect(text('Selected row value')).toBe('true') + expect(text('Selection atom value')).toBe('{"1":true,"2":true}') + expect(ownerRenderCaptor).toHaveBeenCalledTimes(1) + expect(selectedRowRenderCaptor).toHaveBeenCalledTimes(2) + expect(selectionAtomRenderCaptor.mock.calls).toEqual([ + [{}], + [{ 1: true }], + [{ 1: true, 2: true }], + ]) + expect(pageSizeRenderCaptor).toHaveBeenCalledTimes(1) + + act(() => { + click('Set page size to 20') + }) + + expect(text('Subscribed page size')).toBe('20') + expect(ownerRenderCaptor).toHaveBeenCalledTimes(1) + expect(selectedRowRenderCaptor).toHaveBeenCalledTimes(2) + expect(selectionAtomRenderCaptor).toHaveBeenCalledTimes(3) + expect(pageSizeRenderCaptor.mock.calls).toEqual([[10], [20]]) + + act(() => { + click('Select second row too') + click('Set page size to 20') + }) + + expect(ownerRenderCaptor).toHaveBeenCalledTimes(1) + expect(selectedRowRenderCaptor).toHaveBeenCalledTimes(2) + expect(selectionAtomRenderCaptor).toHaveBeenCalledTimes(3) + expect(pageSizeRenderCaptor).toHaveBeenCalledTimes(2) + }) + + test('stops root and isolated Preact observers after unmount', () => { + const rowSelectionAtom = createAtom({}) + const rootStoreSelectorCaptor = vi.fn() + const isolatedStoreCaptor = vi.fn() + const captureTable = + vi.fn<(table: PreactTable) => 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']) + }, + ) + + useLayoutEffect(() => { + captureTable(table) + }, [table]) + + return ( + ({ + selected: Boolean(state.rowSelection['1']), + pageSize: state.pagination.pageSize, + })} + > + {(snapshot) => { + isolatedStoreCaptor(snapshot) + return ( + + {String(snapshot.selected)} + + ) + }} + + ) + } + + const view = render() + + act(() => { + rowSelectionAtom.set({ 1: true }) + }) + + expect(text('Lifecycle selection')).toBe('true') + + view.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] = 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]]) + }) + + test('table APIs use the latest option callback', () => { + const firstHandler = vi.fn>() + const secondHandler = vi.fn>() + + function CallbackHarness() { + const [useSecondHandler, setUseSecondHandler] = useState(false) + const table = useTable( + { + data: [{ id: '1', title: 'Title' }], + features: stockFeatures, + columns, + getRowId: (row) => row.id, + onRowSelectionChange: useSecondHandler ? secondHandler : firstHandler, + }, + () => null, + ) + + return ( + <> + + {useSecondHandler ? 'second' : 'first'} + + + + + ) + } + + render() + + act(() => { + click('Request row selection') + }) + + expect(firstHandler).toHaveBeenCalledTimes(1) + expect(secondHandler).not.toHaveBeenCalled() + + act(() => { + click('Use second selection handler') + }) + + expect(text('Active selection handler')).toBe('second') + + act(() => { + click('Request row selection') + }) + + expect(firstHandler).toHaveBeenCalledTimes(1) + expect(secondHandler).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/preact-table/tests/unit/createTableHook.test.tsx b/packages/preact-table/tests/unit/createTableHook.test.tsx new file mode 100644 index 0000000000..fc4914a364 --- /dev/null +++ b/packages/preact-table/tests/unit/createTableHook.test.tsx @@ -0,0 +1,247 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from '@testing-library/preact' +import { useEffect, useState } from 'preact/hooks' +import { act } from 'preact/test-utils' +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 outputText(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(outputText('Bound row count')).toBe('1') + expect(screen.getByText('Header name')).toBeTruthy() + expect(screen.getByText('ADA')).toBeTruthy() + expect(screen.getByText('Name footer')).toBeTruthy() + expect(outputText('App table selection')).toBe('0') + expect(outputText('App cell selection')).toBe('false') + + act(() => { + click('Select app row') + }) + + expect(outputText('App table selection')).toBe('1') + expect(outputText('App cell selection')).toBe('true') + }) + + test('keeps App wrappers mounted while contexts receive new table objects', () => { + const mountCaptor = vi.fn() + const unmountCaptor = vi.fn() + + function StatefulCell() { + const cell = appTable.useCellContext() + const [draft, setDraft] = useState('initial') + + useEffect(() => { + mountCaptor() + return () => unmountCaptor() + }, []) + + return ( + <> + + setDraft(event.currentTarget.value)} + /> + {cell.getValue()} + + ) + } + + function TableHarness() { + const [updated, setUpdated] = 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.input(input, { target: { value: 'edited' } }) + + expect(outputText('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(outputText('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/preact-table/tests/unit/rendering.test.tsx b/packages/preact-table/tests/unit/rendering.test.tsx new file mode 100644 index 0000000000..ac63526617 --- /dev/null +++ b/packages/preact-table/tests/unit/rendering.test.tsx @@ -0,0 +1,232 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from '@testing-library/preact' +import { useState } from 'preact/hooks' +import { act } from 'preact/test-utils' +import { stockFeatures } from '@tanstack/table-core' +import { afterEach, describe, expect, test, vi } from 'vitest' +import { FlexRender, useTable } from '../../src' +import type { ColumnDef } from '@tanstack/table-core' + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +function outputText(name: string) { + return screen.getByRole('status', { name }).textContent +} + +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:${String(getValue())}`}, + aggregatedCell: ({ getValue }) => ( + {`aggregate:${String(getValue())}`} + ), + footer: ({ column }) => {`footer:${column.id}`}, + }, + ] + + function CellHarness({ mode }: { mode: CellMode }) { + const table = useTable({ + data: [{ id: '1', name: 'Ada' }], + columns, + features: stockFeatures, + getRowId: (row) => row.id, + }) + const cell = table.getRowModel().rows[0]!.getAllCells()[0]! + + if (mode === 'aggregate') { + vi.spyOn(cell, 'getIsAggregated').mockReturnValue(true) + } + + if (mode === 'placeholder') { + vi.spyOn(cell, 'getIsAggregated').mockReturnValue(false) + vi.spyOn(cell, 'getIsPlaceholder').mockReturnValue(true) + } + + return ( + + + + ) + } + + function HeaderFooterHarness() { + const table = useTable({ + 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 normal, aggregate, placeholder, header, and footer content', () => { + render( + <> + + + + + , + ) + + expect(outputText('normal cell')).toBe('cell:Ada') + expect(outputText('aggregate cell')).toBe('aggregate:Ada') + expect(outputText('placeholder cell')).toBe('') + expect(outputText('Rendered header')).toBe('header:name') + expect(outputText('Rendered footer')).toBe('footer:name') + }) + + test('reacts when a stable cell changes grouping mode', () => { + function GroupingCellHarness() { + const [mode, setMode] = useState('normal') + const table = useTable({ + 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() + + expect(outputText('Grouping cell')).toBe('cell:Ada') + + act(() => { + fireEvent.click(screen.getByRole('button', { name: 'Show aggregate' })) + }) + expect(outputText('Grouping cell')).toBe('aggregate:Ada') + + act(() => { + fireEvent.click(screen.getByRole('button', { name: 'Show placeholder' })) + }) + expect(outputText('Grouping cell')).toBe('') + + act(() => { + fireEvent.click(screen.getByRole('button', { name: 'Show normal' })) + }) + expect(outputText('Grouping cell')).toBe('cell:Ada') + }) + + test('updates when the cell prop is replaced with a new instance', () => { + function CellReplacementHarness() { + const [data, setData] = useState>([{ id: '1', name: 'Ada' }]) + const table = useTable({ + data, + columns, + features: stockFeatures, + getRowId: (row) => row.id, + }) + const cell = table.getRowModel().rows[0]!.getAllCells()[0]! + + return ( + <> + + + + + + ) + } + + render() + + expect(outputText('Replaced cell')).toBe('cell:Ada') + + act(() => { + fireEvent.click(screen.getByRole('button', { name: 'Replace cell' })) + }) + + expect(outputText('Replaced cell')).toBe('cell:Grace') + }) +}) + +describe('table.Subscribe', () => { + test('updates mounted content for the selected atom slice', () => { + function SubscribeHarness() { + const table = useTable( + { + data: [{ id: '1' }], + columns: [{ id: 'id', accessorKey: 'id' }], + features: stockFeatures, + getRowId: (row) => row.id, + }, + () => null, + ) + + return ( + <> + Boolean(selection['1'])} + > + {(selected) => ( + + {String(selected)} + + )} + + + + ) + } + + render() + + expect(outputText('Subscribed selection')).toBe('false') + + act(() => { + fireEvent.click( + screen.getByRole('button', { name: 'Select subscribed row' }), + ) + }) + + expect(outputText('Subscribed selection')).toBe('true') + }) +}) diff --git a/packages/preact-table/tests/unit/ssr.test.tsx b/packages/preact-table/tests/unit/ssr.test.tsx new file mode 100644 index 0000000000..55970f78a6 --- /dev/null +++ b/packages/preact-table/tests/unit/ssr.test.tsx @@ -0,0 +1,164 @@ +// @vitest-environment node + +import { forwardRef, memo } from 'preact/compat' +import { renderToString } from 'preact-render-to-string' +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('Preact 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(renderToString()).toBe( + '
Aggregate Ada
' + + '
', + ) + }) + + test('flexRender handles functions, exotic components, nodes, zero, and empty values', () => { + function FunctionRenderable({ value }: { value: string }) { + return {value} + } + + const MemoRenderable = memo(function MemoRenderable({ + value, + }: { + value: string + }) { + return {value} + }) + const ForwardRefRenderable = forwardRef( + function ForwardRefRenderable({ value }, ref) { + return ( + + {value} + + ) + }, + ) + + const markup = renderToString( + <> + {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(0, {})).toBe(0) + expect(flexRender(false, {})).toBe(false) + expect(flexRender('', {})).toBe('') + expect(flexRender(null, {})).toBeNull() + }) +}) diff --git a/packages/preact-table/tests/unit/useTable.test.tsx b/packages/preact-table/tests/unit/useTable.test.tsx index 08a07f89f5..853644f9a8 100644 --- a/packages/preact-table/tests/unit/useTable.test.tsx +++ b/packages/preact-table/tests/unit/useTable.test.tsx @@ -1,4 +1,4 @@ -import { render } from 'preact' +import { cleanup, fireEvent, render, screen } from '@testing-library/preact' import { useState } from 'preact/hooks' import { act } from 'preact/test-utils' import { @@ -6,7 +6,8 @@ import { rowPaginationFeature, tableFeatures, } from '@tanstack/table-core' -import { afterEach, describe, expect, it } from 'vitest' +import { createAtom } from '@tanstack/preact-store' +import { afterEach, describe, expect, it, vi } from 'vitest' import { useTable } from '../../src/useTable' import type { ColumnDef, PaginationState } from '@tanstack/table-core' import type { PreactTable } from '../../src/useTable' @@ -25,32 +26,17 @@ const data: ReadonlyArray = Array.from({ length: 100 }, (_, id) => ({ })) const columns: ReadonlyArray> = [] -let container: HTMLDivElement | undefined - -function mount(ui: preact.ComponentChildren) { - container = document.createElement('div') - document.body.append(container) - act(() => { - render(ui as any, container!) - }) +function text(name: string) { + return screen.getByRole('status', { name }).textContent } -function clickButton() { - container?.querySelector('button')?.dispatchEvent( - new MouseEvent('click', { - bubbles: true, - }), - ) +function clickButton(name = 'Next page') { + fireEvent.click(screen.getByRole('button', { name })) } afterEach(() => { - if (container) { - act(() => { - render(null, container!) - }) - container.remove() - container = undefined - } + cleanup() + vi.restoreAllMocks() }) describe('useTable controlled state', () => { @@ -92,7 +78,7 @@ describe('useTable controlled state', () => { return } - mount() + render() expect(renderSnapshots).toHaveLength(1) act(() => { @@ -172,7 +158,7 @@ describe('useTable controlled state', () => { return } - mount() + render() const notifications: Array = [] const subscription = latestTable!.store.subscribe((state) => { @@ -205,18 +191,305 @@ describe('useTable controlled state', () => { (state) => state.pagination.pageIndex, ) - return + return ( + <> + {table.state} + + + ) } - mount() + render() expect(harnessRenderCount).toBe(1) - expect(container?.querySelector('button')?.textContent).toBe('0') + expect(text('Uncontrolled page index')).toBe('0') act(() => { clickButton() }) expect(harnessRenderCount).toBe(2) - expect(container?.querySelector('button')?.textContent).toBe('1') + expect(text('Uncontrolled page index')).toBe('1') + }) + + it('releases and reacquires ownership when the controlled slice is omitted', () => { + let harnessRenderCount = 0 + + function OwnershipHarness() { + harnessRenderCount++ + const [controlled, setControlled] = useState(true) + const [pagination] = useState({ + pageIndex: 5, + pageSize: 10, + }) + const table = useTable( + { + features, + columns, + data, + state: controlled ? { pagination } : {}, + }, + (state) => state.pagination, + ) + + return ( + <> + + {table.state.pageIndex} + + + + + ) + } + + render() + + expect(text('Ownership page index')).toBe('5') + expect(harnessRenderCount).toBe(1) + + act(() => { + clickButton('Toggle ownership') + }) + + expect(text('Ownership page index')).toBe('5') + expect(harnessRenderCount).toBe(2) + + act(() => { + clickButton() + }) + + expect(text('Ownership page index')).toBe('6') + expect(harnessRenderCount).toBe(3) + + act(() => { + clickButton('Toggle ownership') + }) + + expect(text('Ownership page index')).toBe('5') + expect(harnessRenderCount).toBe(4) + + act(() => { + clickButton() + }) + + expect(text('Ownership page index')).toBe('5') + expect(harnessRenderCount).toBe(4) + }) + + it('invalidates an isolated subscriber when the controlled slice is omitted', () => { + function OwnershipReleaseHarness() { + const [controlled, setControlled] = useState(true) + const [pagination] = useState({ + pageIndex: 5, + pageSize: 10, + }) + const table = useTable( + { + features, + columns, + data, + state: controlled ? { pagination } : {}, + }, + () => null, + ) + + return ( + <> + state.pagination.pageIndex}> + {(pageIndex) => ( + {pageIndex} + )} + + + + + ) + } + + render() + + expect(text('Isolated page index')).toBe('5') + + act(() => { + clickButton('Change base') + }) + + expect(text('Isolated page index')).toBe('5') + + act(() => { + clickButton('Release control') + }) + + expect(text('Isolated page index')).toBe('6') + }) + + it('gives an external atom precedence over controlled state and writes back to it', () => { + const paginationAtom = createAtom({ + pageIndex: 3, + pageSize: 10, + }) + + function ExternalAtomHarness() { + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }) + const table = useTable( + { + features, + columns, + data, + state: { pagination }, + atoms: { + pagination: paginationAtom, + }, + }, + (state) => state.pagination.pageIndex, + ) + + return ( + <> + {table.state} + + + + ) + } + + render() + + expect(text('External page index')).toBe('3') + + act(() => { + clickButton('Change controlled page') + }) + + expect(text('External page index')).toBe('3') + expect(paginationAtom.get().pageIndex).toBe(3) + + act(() => { + clickButton() + }) + + expect(paginationAtom.get().pageIndex).toBe(4) + expect(text('External page index')).toBe('4') + }) + + it('keeps the owner stable when an update misses its selected slice', () => { + let harnessRenderCount = 0 + + function SelectedPageIndexHarness() { + harnessRenderCount++ + const table = useTable( + { + features, + columns, + data, + }, + (state) => state.pagination.pageIndex, + ) + + return ( + <> + {table.state} + state.pagination.pageSize}> + {(pageSize) => ( + {pageSize} + )} + + + + + ) + } + + render() + + expect(harnessRenderCount).toBe(1) + expect(text('Selected page index')).toBe('0') + expect(text('Isolated page size')).toBe('10') + + act(() => { + clickButton('Set page size to 20') + }) + + expect(harnessRenderCount).toBe(1) + expect(text('Selected page index')).toBe('0') + expect(text('Isolated page size')).toBe('20') + + act(() => { + clickButton() + }) + + expect(harnessRenderCount).toBe(2) + expect(text('Selected page index')).toBe('1') + }) + + it('publishes the final value after rapid controlled updates', () => { + function RapidUpdateHarness() { + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }) + const table = useTable( + { + features, + columns, + data, + state: { pagination }, + onPaginationChange: setPagination, + }, + () => null, + ) + + return ( + <> + + {pagination.pageIndex} + + state.pagination.pageIndex}> + {(pageIndex) => ( + + {pageIndex} + + )} + + + + ) + } + + render() + + expect(text('Controlled page index')).toBe('0') + expect(text('Rapid isolated page index')).toBe('0') + + act(() => { + clickButton('Advance three pages') + }) + + expect(text('Controlled page index')).toBe('3') + expect(text('Rapid isolated page index')).toBe('3') }) }) diff --git a/packages/react-table/tests/adapterReactivity.test.tsx b/packages/react-table/tests/adapterReactivity.test.tsx index a2af8c9a33..3fe4e24724 100644 --- a/packages/react-table/tests/adapterReactivity.test.tsx +++ b/packages/react-table/tests/adapterReactivity.test.tsx @@ -95,7 +95,10 @@ describe('React adapter reactivity and lifecycle', () => { function TableHarness() { const table = useTable( { - data: [{ id: '1', title: 'Title' }], + data: [ + { id: '1', title: 'First' }, + { id: '2', title: 'Second' }, + ], features: stockFeatures, columns, getRowId: (row) => row.id, @@ -277,6 +280,125 @@ describe('React adapter reactivity and lifecycle', () => { expect(text('External row selection')).toBe('{}') }) + test('isolates Subscribe render cycles to each selected dependency', () => { + const ownerRenderCaptor = vi.fn() + const selectedRowRenderCaptor = vi.fn<(selected: boolean) => void>() + const selectionAtomRenderCaptor = + vi.fn<(state: RowSelectionState) => void>() + const pageSizeRenderCaptor = vi.fn<(pageSize: number) => void>() + + function TableHarness() { + ownerRenderCaptor() + const table = useTable( + { + data: [{ id: '1', title: 'Title' }], + features: stockFeatures, + columns, + getRowId: (row) => row.id, + }, + () => null, + ) + + return ( + <> + Boolean(state.rowSelection['1'])} + > + {(selected) => { + selectedRowRenderCaptor(selected) + return ( + + {String(selected)} + + ) + }} + + + {(selection) => { + selectionAtomRenderCaptor(selection) + return ( + + {JSON.stringify(selection)} + + ) + }} + + state.pagination.pageSize}> + {(pageSize) => { + pageSizeRenderCaptor(pageSize) + return ( + {pageSize} + ) + }} + + + + + + ) + } + + render() + + expect(ownerRenderCaptor).toHaveBeenCalledTimes(1) + expect(selectedRowRenderCaptor.mock.calls).toEqual([[false]]) + expect(selectionAtomRenderCaptor.mock.calls).toEqual([[{}]]) + expect(pageSizeRenderCaptor.mock.calls).toEqual([[10]]) + + act(() => { + click('Select first row') + }) + + expect(text('Selected row value')).toBe('true') + expect(text('Selection atom value')).toBe('{"1":true}') + expect(text('Subscribed page size')).toBe('10') + expect(ownerRenderCaptor).toHaveBeenCalledTimes(1) + expect(selectedRowRenderCaptor.mock.calls).toEqual([[false], [true]]) + expect(selectionAtomRenderCaptor.mock.calls).toEqual([[{}], [{ 1: true }]]) + expect(pageSizeRenderCaptor.mock.calls).toEqual([[10]]) + + act(() => { + click('Select second row too') + }) + + expect(text('Selected row value')).toBe('true') + expect(text('Selection atom value')).toBe('{"1":true,"2":true}') + expect(ownerRenderCaptor).toHaveBeenCalledTimes(1) + expect(selectedRowRenderCaptor).toHaveBeenCalledTimes(2) + expect(selectionAtomRenderCaptor.mock.calls).toEqual([ + [{}], + [{ 1: true }], + [{ 1: true, 2: true }], + ]) + expect(pageSizeRenderCaptor).toHaveBeenCalledTimes(1) + + act(() => { + click('Set page size to 20') + }) + + expect(text('Subscribed page size')).toBe('20') + expect(ownerRenderCaptor).toHaveBeenCalledTimes(1) + expect(selectedRowRenderCaptor).toHaveBeenCalledTimes(2) + expect(selectionAtomRenderCaptor).toHaveBeenCalledTimes(3) + expect(pageSizeRenderCaptor.mock.calls).toEqual([[10], [20]]) + + act(() => { + click('Select second row too') + click('Set page size to 20') + }) + + expect(ownerRenderCaptor).toHaveBeenCalledTimes(1) + expect(selectedRowRenderCaptor).toHaveBeenCalledTimes(2) + expect(selectionAtomRenderCaptor).toHaveBeenCalledTimes(3) + expect(pageSizeRenderCaptor).toHaveBeenCalledTimes(2) + }) + test('stops root and isolated React observers after unmount', () => { const rowSelectionAtom = createAtom({}) const rootStoreSelectorCaptor = vi.fn() diff --git a/packages/svelte-table/package.json b/packages/svelte-table/package.json index da99bc8444..88822e4ceb 100644 --- a/packages/svelte-table/package.json +++ b/packages/svelte-table/package.json @@ -57,7 +57,9 @@ ], "scripts": { "clean": "rimraf ./build && rimraf ./dist", - "test:eslint": "eslint ./src", + "test:eslint": "eslint ./src ./tests", + "test:lib": "vitest --passWithNoTests", + "test:lib:dev": "pnpm test:lib --watch", "test:types": "svelte-check --tsconfig ./tsconfig.json", "test:build": "publint --strict", "build": "svelte-package --input ./src --output ./dist" @@ -69,6 +71,7 @@ "devDependencies": { "@sveltejs/package": "^2.5.8", "@sveltejs/vite-plugin-svelte": "^7.1.2", + "@testing-library/svelte": "^5.4.2", "eslint-plugin-svelte": "^3.19.0", "svelte": "^5.56.2", "svelte-check": "^4.6.0" diff --git a/packages/svelte-table/src/FlexRender.svelte b/packages/svelte-table/src/FlexRender.svelte index dbe33cb15a..23ee661391 100644 --- a/packages/svelte-table/src/FlexRender.svelte +++ b/packages/svelte-table/src/FlexRender.svelte @@ -61,8 +61,22 @@ // or the legacy content/context props. const resolved = $derived.by(() => { if ('cell' in props && props.cell) { + const columnDef = props.cell.column.columnDef + const groupingCell = props.cell as typeof props.cell & { + getIsAggregated?: () => boolean + getIsPlaceholder?: () => boolean + } + const groupingColumnDef = columnDef as typeof columnDef & { + aggregatedCell?: typeof columnDef.cell + } + const content = groupingCell.getIsAggregated?.() + ? (groupingColumnDef.aggregatedCell ?? columnDef.cell) + : groupingCell.getIsPlaceholder?.() + ? undefined + : columnDef.cell + return { - content: props.cell.column.columnDef.cell, + content, context: props.cell.getContext(), } } diff --git a/packages/svelte-table/src/createTable.svelte.ts b/packages/svelte-table/src/createTable.svelte.ts index 5751493a58..068352daaa 100644 --- a/packages/svelte-table/src/createTable.svelte.ts +++ b/packages/svelte-table/src/createTable.svelte.ts @@ -101,22 +101,23 @@ export function createTable< // inside createTableState), the effect re-runs and calls setOptions. // Use $effect.pre so the table sees updated options BEFORE the DOM renders, // ensuring getRowModel() returns current data (not stale, one-frame-behind data). - // The reactive reads (state getters, data getter) happen OUTSIDE untrack - // so they become dependencies. The setOptions call is INSIDE untrack so - // option writes do not subscribe this effect to table internals. + // Reactive option getters and nested state keys are read OUTSIDE untrack so + // they become dependencies. The setOptions call is INSIDE untrack so option + // writes do not subscribe this effect to table internals. $effect.pre(() => { - // Read reactive getters to create $effect dependencies on external state - const state: Record | undefined = mergedOptions.state + // Resolve every option outside untrack so getter-backed data, columns, + // callbacks, metadata, and state become dependencies of this effect. + const nextOptions = flatMerge(mergedOptions) + const state = nextOptions.state as Record | undefined if (state) { for (const key in state) { void state[key] } } - void mergedOptions.data untrack(() => { table.setOptions((prev) => { - return flatMerge(prev, mergedOptions) + return flatMerge(prev, nextOptions) }) }) }) diff --git a/packages/svelte-table/src/createTableHook.svelte.ts b/packages/svelte-table/src/createTableHook.svelte.ts index 36e9417be4..afe06809e0 100644 --- a/packages/svelte-table/src/createTableHook.svelte.ts +++ b/packages/svelte-table/src/createTableHook.svelte.ts @@ -38,6 +38,50 @@ import type { } from '@tanstack/table-core' export type ComponentType> = Component +export type BoundFlexRenderComponent = Component> + +function createLiveView( + getCurrent: () => TCurrent, + extensions: TExtensions, +): TCurrent & TExtensions { + const hasExtension = (key: PropertyKey) => + Object.prototype.hasOwnProperty.call(extensions, key) + + return new Proxy({} as TCurrent & TExtensions, { + get: (_target, key) => { + if (hasExtension(key)) { + return Reflect.get(extensions, key, extensions) + } + const current = getCurrent() + return Reflect.get(current, key, current) + }, + getOwnPropertyDescriptor: (_target, key) => { + const source = hasExtension(key) ? extensions : getCurrent() + const descriptor = Reflect.getOwnPropertyDescriptor(source, key) + if (!descriptor) return undefined + + return { + configurable: true, + enumerable: descriptor.enumerable, + get: () => Reflect.get(source, key, source), + set: (value) => { + Reflect.set(source, key, value, source) + }, + } + }, + has: (_target, key) => hasExtension(key) || Reflect.has(getCurrent(), key), + ownKeys: () => [ + ...new Set([ + ...Reflect.ownKeys(getCurrent()), + ...Reflect.ownKeys(extensions), + ]), + ], + set: (_target, key, value) => { + const source = hasExtension(key) ? extensions : getCurrent() + return Reflect.set(source, key, value, source) + }, + }) +} // ============================================================================= // Enhanced Context Types with Pre-bound Components @@ -54,7 +98,7 @@ export type AppCellContext< TCellComponents extends Record>, > = { cell: Cell & - TCellComponents & { FlexRender: typeof FlexRenderSvelte } + TCellComponents & { FlexRender: BoundFlexRenderComponent } column: Column getValue: CellContext['getValue'] renderValue: CellContext['renderValue'] @@ -74,7 +118,7 @@ export type AppHeaderContext< > = { column: Column header: Header & - THeaderComponents & { FlexRender: typeof FlexRenderSvelte } + THeaderComponents & { FlexRender: BoundFlexRenderComponent } table: Table } @@ -322,7 +366,9 @@ export type AppSvelteTable< children: Snippet< [ Cell & - NoInfer & { FlexRender: typeof FlexRenderSvelte }, + NoInfer & { + FlexRender: BoundFlexRenderComponent + }, ] > }> @@ -343,7 +389,7 @@ export type AppSvelteTable< [ Header & NoInfer & { - FlexRender: typeof FlexRenderSvelte + FlexRender: BoundFlexRenderComponent }, ] > @@ -365,7 +411,7 @@ export type AppSvelteTable< [ Header & NoInfer & { - FlexRender: typeof FlexRenderSvelte + FlexRender: BoundFlexRenderComponent }, ] > @@ -440,7 +486,7 @@ export interface CreateTableHookResult< any, TValue > & - TCellComponents & { FlexRender: typeof FlexRenderSvelte } + TCellComponents & { FlexRender: BoundFlexRenderComponent } /** * Reads the header provided by the nearest `` / * ``, extended with your `headerComponents` and a @@ -451,7 +497,7 @@ export interface CreateTableHookResult< any, TValue > & - THeaderComponents & { FlexRender: typeof FlexRenderSvelte } + THeaderComponents & { FlexRender: BoundFlexRenderComponent } } // ============================================================================= @@ -581,7 +627,7 @@ export function createTableHook< any, TValue > & - TCellComponents & { FlexRender: typeof FlexRenderSvelte } { + TCellComponents & { FlexRender: BoundFlexRenderComponent } { const cell = getContext(cellContextKey) if (!cell) { @@ -591,11 +637,11 @@ export function createTableHook< ) } - // `` Object.assign-es `cellComponents` and `FlexRender` onto - // the same cell instance it puts in context, so this asserts the runtime - // shape. + // `` provides a live view of the current cell and its bound + // components, so same-key row-model replacements stay current without + // remounting context consumers. return cell as unknown as Cell & - TCellComponents & { FlexRender: typeof FlexRenderSvelte } + TCellComponents & { FlexRender: BoundFlexRenderComponent } } /** @@ -608,7 +654,7 @@ export function createTableHook< any, TValue > & - THeaderComponents & { FlexRender: typeof FlexRenderSvelte } { + THeaderComponents & { FlexRender: BoundFlexRenderComponent } { const header = getContext(headerContextKey) if (!header) { @@ -617,10 +663,10 @@ export function createTableHook< ) } - // `` / `` Object.assign `headerComponents` - // and `FlexRender` onto the same header instance they put in context. + // `` / `` provide a live view of the + // current header and its bound components. return header as unknown as Header & - THeaderComponents & { FlexRender: typeof FlexRenderSvelte } + THeaderComponents & { FlexRender: BoundFlexRenderComponent } } /** @@ -657,60 +703,103 @@ export function createTableHook< selector, ) - // Build cellComponents with FlexRender included - const cellComponentsWithFlexRender = { - FlexRender: FlexRenderSvelte, - ...(cellComponents ?? {}), - } - - // Build headerComponents with FlexRender included - const headerComponentsWithFlexRender = { - FlexRender: FlexRenderSvelte, - ...(headerComponents ?? {}), - } - - // Create wrapper components using the svelte-form (internal, props) => pattern. - // setContext is called in the closure — this runs during component - // initialization, so Svelte's context API works correctly. - // With keyed {#each} blocks, components are recreated on reorder, - // so context is always fresh. + // Create wrapper components using the svelte-form (internal, props) => + // pattern. Svelte keeps the props object live through getter-backed + // properties, so preserve it instead of destructuring or spreading it. + // Context consumers receive a getter-backed view for the same reason: + // row-model refreshes can replace a cell/header without remounting the + // wrapper component. const AppTable = ((internal: any, props: any) => { setContext(tableContextKey, table) - return AppTableSvelte(internal, { ...props }) + return AppTableSvelte(internal, props) }) as Component<{ children: Snippet }> - const AppCell = ((internal: any, { children, cell }: any) => { - setContext(cellContextKey, cell) - return AppCellSvelte(internal, { - cell, - cellComponents: cellComponentsWithFlexRender, - children, - }) + const AppCell = ((internal: any, props: any) => { + const BoundFlexRender = ((componentInternal: any, renderProps: any) => { + return FlexRenderSvelte( + componentInternal, + mergeObjects(renderProps, { + get cell() { + return props.cell + }, + }), + ) + }) as BoundFlexRenderComponent + const boundComponents = { + FlexRender: BoundFlexRender, + ...(cellComponents ?? {}), + } + const liveCell = createLiveView(() => props.cell, boundComponents) + + setContext(cellContextKey, liveCell) + + return AppCellSvelte( + internal, + mergeObjects(props, { + cellComponents: boundComponents, + }), + ) }) as Component<{ cell: Cell children: Snippet<[any]> }> - const AppHeader = ((internal: any, { children, header }: any) => { - setContext(headerContextKey, header) - return AppHeaderSvelte(internal, { - header, - headerComponents: headerComponentsWithFlexRender, - children, - }) + const AppHeader = ((internal: any, props: any) => { + const BoundFlexRender = ((componentInternal: any, renderProps: any) => { + return FlexRenderSvelte( + componentInternal, + mergeObjects(renderProps, { + get header() { + return props.header + }, + }), + ) + }) as BoundFlexRenderComponent + const boundComponents = { + FlexRender: BoundFlexRender, + ...(headerComponents ?? {}), + } + const liveHeader = createLiveView(() => props.header, boundComponents) + + setContext(headerContextKey, liveHeader) + + return AppHeaderSvelte( + internal, + mergeObjects(props, { + headerComponents: boundComponents, + }), + ) }) as Component<{ header: Header children: Snippet<[any]> }> // AppFooter reuses AppHeaderSvelte (footers use Header type in table-core) - const AppFooter = ((internal: any, { children, header }: any) => { - setContext(headerContextKey, header) - return AppHeaderSvelte(internal, { - header, - headerComponents: headerComponentsWithFlexRender, - children, - }) + const AppFooter = ((internal: any, props: any) => { + const BoundFlexRender = ((componentInternal: any, renderProps: any) => { + return FlexRenderSvelte( + componentInternal, + mergeObjects(renderProps, { + get footer() { + return props.header + }, + }), + ) + }) as BoundFlexRenderComponent + const boundComponents = { + FlexRender: BoundFlexRender, + ...(headerComponents ?? {}), + } + const liveHeader = createLiveView(() => props.header, boundComponents) + + setContext(headerContextKey, liveHeader) + + return AppHeaderSvelte( + internal, + mergeObjects(props, { + headerComponents: boundComponents, + }), + ) }) as Component<{ header: Header children: Snippet<[any]> diff --git a/packages/svelte-table/src/index.ts b/packages/svelte-table/src/index.ts index 75c8ab8fbf..8ef8f34d54 100644 --- a/packages/svelte-table/src/index.ts +++ b/packages/svelte-table/src/index.ts @@ -12,6 +12,7 @@ export type { AppGroupColumnDef, AppHeaderContext, AppSvelteTable, + BoundFlexRenderComponent, ComponentType, CreateTableHookOptions, CreateTableHookResult, diff --git a/packages/svelte-table/src/reactivity.svelte.ts b/packages/svelte-table/src/reactivity.svelte.ts index bfe5bef784..22648c714b 100644 --- a/packages/svelte-table/src/reactivity.svelte.ts +++ b/packages/svelte-table/src/reactivity.svelte.ts @@ -32,19 +32,26 @@ function subscribeToRune( } function createRuneWritableAtom(initialValue: T): Atom { - let value = $state(initialValue) + const storeAtom = createAtom(initialValue) + let version = $state(0) return { set: (updater: T | ((prevVal: T) => T)) => { - value = - typeof updater === 'function' - ? (updater as (prevVal: T) => T)(value) - : updater + const previous = storeAtom.get() + if (typeof updater === 'function') { + storeAtom.set(updater as (prevVal: T) => T) + } else { + storeAtom.set(updater) + } + if (!Object.is(previous, storeAtom.get())) { + version += 1 + } + }, + get: () => { + version + return storeAtom.get() }, - get: () => value, - subscribe: ((observerOrNext: Observer | ((value: T) => void)) => { - return subscribeToRune(() => value, observerOrNext) - }) as Atom['subscribe'], + subscribe: storeAtom.subscribe, } } diff --git a/packages/svelte-table/tests/adapter-lifecycle.test.ts b/packages/svelte-table/tests/adapter-lifecycle.test.ts new file mode 100644 index 0000000000..d67432d4ee --- /dev/null +++ b/packages/svelte-table/tests/adapter-lifecycle.test.ts @@ -0,0 +1,227 @@ +// @vitest-environment jsdom + +import { describe, expect, test, vi } from 'vitest' +import { act, fireEvent, render, screen } from '@testing-library/svelte' +import { createAtom } from '@tanstack/svelte-store' +import CallbackHarness from './fixtures/CallbackHarness.svelte' +import PaginationHarness from './fixtures/PaginationHarness.svelte' +import ReactivityHarness from './fixtures/ReactivityHarness.svelte' +import SelectorHarness from './fixtures/SelectorHarness.svelte' +import type { OnChangeFn, RowSelectionState } from '@tanstack/table-core' + +function outputText(name: string) { + return screen.getByRole('status', { name }).textContent +} + +describe('Svelte adapter lifecycle and reactive options', () => { + test('unmount unsubscribes external atoms and stops later reactions', async () => { + const externalRowSelection = createAtom({ 2: true }) + const subscribeSpy = vi.spyOn(externalRowSelection, 'subscribe') + const selectionCaptor = vi.fn<(selection: RowSelectionState) => void>() + const selectionStoreCaptor = + vi.fn<(store: { readonly current: RowSelectionState }) => void>() + const { unmount } = render(ReactivityHarness, { + externalRowSelection, + selectionCaptor, + selectionStoreCaptor, + }) + + await act() + + expect(outputText('Selected rows')).toBe('{"2":true}') + expect(selectionCaptor.mock.calls).toEqual([[{ 2: true }]]) + expect(subscribeSpy).toHaveBeenCalled() + + await act(() => externalRowSelection.set({ 1: true })) + + expect(outputText('Selected rows')).toBe('{"1":true}') + expect(selectionCaptor.mock.calls).toEqual([[{ 2: true }], [{ 1: true }]]) + expect(selectionStoreCaptor).toHaveBeenCalledOnce() + + unmount() + + await act(() => externalRowSelection.set({ 2: true })) + + expect(selectionCaptor.mock.calls).toEqual([[{ 2: true }], [{ 1: true }]]) + expect(selectionStoreCaptor.mock.lastCall?.[0].current).toEqual({ + 1: true, + }) + }) + + test('controlled state can release and reacquire ownership without losing the latest value', async () => { + render(ReactivityHarness) + + expect(outputText('Selected rows')).toBe('{"1":true}') + expect(outputText('Selected count')).toBe('1') + + await fireEvent.click( + screen.getByRole('button', { name: 'Release selection ownership' }), + ) + expect(outputText('Selected rows')).toBe('{"1":true}') + + await fireEvent.click( + screen.getByRole('button', { name: 'Select second row' }), + ) + expect(outputText('Selected rows')).toBe('{"2":true}') + + await fireEvent.click( + screen.getByRole('button', { name: 'Control both rows' }), + ) + expect(outputText('Selected rows')).toBe('{"1":true,"2":true}') + expect(outputText('Selected count')).toBe('2') + + await fireEvent.click( + screen.getByRole('button', { name: 'Clear selection through table' }), + ) + expect(outputText('Selected rows')).toBe('{"1":true,"2":true}') + + await fireEvent.click( + screen.getByRole('button', { name: 'Release selection ownership' }), + ) + expect(outputText('Selected rows')).toBe('{}') + expect(outputText('Selected count')).toBe('0') + }) + + test('external atoms take precedence over controlled state and receive table updates', async () => { + const externalRowSelection = createAtom({ 2: true }) + render(ReactivityHarness, { externalRowSelection }) + + expect(outputText('Selected rows')).toBe('{"2":true}') + + await fireEvent.click( + screen.getByRole('button', { name: 'Control both rows' }), + ) + expect(outputText('Selected rows')).toBe('{"2":true}') + + await act(() => externalRowSelection.set({ 1: true })) + expect(outputText('Selected rows')).toBe('{"1":true}') + + await fireEvent.click( + screen.getByRole('button', { name: 'Select second row' }), + ) + expect(externalRowSelection.get()).toEqual({ 2: true }) + expect(outputText('Selected rows')).toBe('{"2":true}') + }) + + test('rapid updates publish only the final data, columns, and option values', async () => { + const snapshotCaptor = vi.fn() + render(ReactivityHarness, { snapshotCaptor }) + + await act() + + expect(snapshotCaptor.mock.calls).toEqual([ + [ + { + canSelect: true, + columnIds: ['id'], + rowIds: ['1', '2'], + values: ['1'], + }, + ], + ]) + + await fireEvent.click( + screen.getByRole('button', { name: 'Publish rapid option updates' }), + ) + + expect(outputText('Row ids')).toBe('4') + expect(outputText('Column ids')).toBe('title') + expect(outputText('First row values')).toBe('Final') + expect(outputText('First row can be selected')).toBe('false') + expect(snapshotCaptor.mock.calls).toEqual([ + [ + { + canSelect: true, + columnIds: ['id'], + rowIds: ['1', '2'], + values: ['1'], + }, + ], + [ + { + canSelect: false, + columnIds: ['title'], + rowIds: ['4'], + values: ['Final'], + }, + ], + ]) + }) + + test('table APIs use the latest rune-backed option callback', async () => { + const firstHandler = vi.fn>() + const secondHandler = vi.fn>() + render(CallbackHarness, { firstHandler, secondHandler }) + + await fireEvent.click( + screen.getByRole('button', { name: 'Toggle all rows selected' }), + ) + expect(firstHandler).toHaveBeenCalledOnce() + expect(secondHandler).not.toHaveBeenCalled() + + await fireEvent.click( + screen.getByRole('button', { name: 'Use second selection handler' }), + ) + await fireEvent.click( + screen.getByRole('button', { name: 'Toggle all rows selected' }), + ) + + expect(firstHandler).toHaveBeenCalledOnce() + expect(secondHandler).toHaveBeenCalledOnce() + }) + + test('row models react to createTableState-controlled pagination', async () => { + render(PaginationHarness) + + expect(outputText('Paginated row ids')).toBe('0,1,2,3,4') + + await fireEvent.click( + screen.getByRole('button', { name: 'Show three rows' }), + ) + + expect(outputText('Paginated row ids')).toBe('0,1,2') + }) + + test('selector subscriptions only rerun for their selected dependency', async () => { + const selectedRowCaptor = vi.fn<(selected: boolean) => void>() + const wholeSelectionCaptor = vi.fn<(selection: RowSelectionState) => void>() + + render(SelectorHarness, { selectedRowCaptor, wholeSelectionCaptor }) + await act() + + expect(outputText('Selected first row')).toBe('false') + expect(outputText('Whole row selection')).toBe('{}') + expect(selectedRowCaptor.mock.calls).toEqual([[false]]) + expect(wholeSelectionCaptor.mock.calls).toEqual([[{}]]) + + await fireEvent.click( + screen.getByRole('button', { name: 'Select first row' }), + ) + + expect(selectedRowCaptor.mock.calls).toEqual([[false], [true]]) + expect(wholeSelectionCaptor.mock.calls).toEqual([[{}], [{ 1: true }]]) + + await fireEvent.click( + screen.getByRole('button', { name: 'Select second row too' }), + ) + + expect(outputText('Selected first row')).toBe('true') + expect(outputText('Whole row selection')).toBe('{"1":true,"2":true}') + expect(selectedRowCaptor).toHaveBeenCalledTimes(2) + expect(wholeSelectionCaptor.mock.calls).toEqual([ + [{}], + [{ 1: true }], + [{ 1: true, 2: true }], + ]) + + await fireEvent.click( + screen.getByRole('button', { name: 'Set unrelated page size' }), + ) + await fireEvent.click( + screen.getByRole('button', { name: 'Select second row too' }), + ) + + expect(selectedRowCaptor).toHaveBeenCalledTimes(2) + expect(wholeSelectionCaptor).toHaveBeenCalledTimes(3) + }) +}) diff --git a/packages/svelte-table/tests/fixtures/CallbackHarness.svelte b/packages/svelte-table/tests/fixtures/CallbackHarness.svelte new file mode 100644 index 0000000000..9bf7977e3a --- /dev/null +++ b/packages/svelte-table/tests/fixtures/CallbackHarness.svelte @@ -0,0 +1,34 @@ + + + + diff --git a/packages/svelte-table/tests/fixtures/ContextFailure.svelte b/packages/svelte-table/tests/fixtures/ContextFailure.svelte new file mode 100644 index 0000000000..3aa7e04a72 --- /dev/null +++ b/packages/svelte-table/tests/fixtures/ContextFailure.svelte @@ -0,0 +1,11 @@ + diff --git a/packages/svelte-table/tests/fixtures/FlexRenderHarness.svelte b/packages/svelte-table/tests/fixtures/FlexRenderHarness.svelte new file mode 100644 index 0000000000..2918613b3f --- /dev/null +++ b/packages/svelte-table/tests/fixtures/FlexRenderHarness.svelte @@ -0,0 +1,88 @@ + + + + + + + + + + + renderSnippet(snippetRenderer, { label: 'snippet:Ada' })} + context={{} as any} + /> + + + + + + + +{#snippet snippetRenderer(props: { label: string })} + {props.label} +{/snippet} diff --git a/packages/svelte-table/tests/fixtures/HookCellBadge.svelte b/packages/svelte-table/tests/fixtures/HookCellBadge.svelte new file mode 100644 index 0000000000..878625b40f --- /dev/null +++ b/packages/svelte-table/tests/fixtures/HookCellBadge.svelte @@ -0,0 +1,7 @@ + + +cell-component:{cell.getValue()} diff --git a/packages/svelte-table/tests/fixtures/HookHarness.svelte b/packages/svelte-table/tests/fixtures/HookHarness.svelte new file mode 100644 index 0000000000..6514069f6d --- /dev/null +++ b/packages/svelte-table/tests/fixtures/HookHarness.svelte @@ -0,0 +1,80 @@ + + + + + {String(row.getCanSelect())} + + + {#snippet children(value)} + + + {/snippet} + + + + {#snippet children(value)} + + + {/snippet} + + + + {#snippet children(value)} + + {/snippet} + + + + diff --git a/packages/svelte-table/tests/fixtures/HookHeaderBadge.svelte b/packages/svelte-table/tests/fixtures/HookHeaderBadge.svelte new file mode 100644 index 0000000000..1e76a65de3 --- /dev/null +++ b/packages/svelte-table/tests/fixtures/HookHeaderBadge.svelte @@ -0,0 +1,7 @@ + + +header-component:{header.column.id} diff --git a/packages/svelte-table/tests/fixtures/HookTableBadge.svelte b/packages/svelte-table/tests/fixtures/HookTableBadge.svelte new file mode 100644 index 0000000000..fe0db57d65 --- /dev/null +++ b/packages/svelte-table/tests/fixtures/HookTableBadge.svelte @@ -0,0 +1,8 @@ + + +table-component:{table.getRowModel().rows[0]?.id} diff --git a/packages/svelte-table/tests/fixtures/PaginationHarness.svelte b/packages/svelte-table/tests/fixtures/PaginationHarness.svelte new file mode 100644 index 0000000000..d3103b9a69 --- /dev/null +++ b/packages/svelte-table/tests/fixtures/PaginationHarness.svelte @@ -0,0 +1,45 @@ + + +{table + .getRowModel() + .rows.map((row) => row.id) + .join(',')} + diff --git a/packages/svelte-table/tests/fixtures/ReactivityHarness.svelte b/packages/svelte-table/tests/fixtures/ReactivityHarness.svelte new file mode 100644 index 0000000000..b72d1a07c8 --- /dev/null +++ b/packages/svelte-table/tests/fixtures/ReactivityHarness.svelte @@ -0,0 +1,152 @@ + + +{JSON.stringify(selected.current)} +{table.state.selectedCount} +{table + .getRowModel() + .rows.map((row) => row.id) + .join(',')} +{table + .getAllLeafColumns() + .map((column) => column.id) + .join(',')} +{table + .getRowModel() + .rows[0]?.getAllCells() + .map((cell) => cell.getValue()) + .join(',') ?? ''} +{String(table.getRowModel().rows[0]?.getCanSelect() ?? false)} + + + + + + diff --git a/packages/svelte-table/tests/fixtures/RenderBadge.svelte b/packages/svelte-table/tests/fixtures/RenderBadge.svelte new file mode 100644 index 0000000000..2964cb0706 --- /dev/null +++ b/packages/svelte-table/tests/fixtures/RenderBadge.svelte @@ -0,0 +1,9 @@ + + +{label} diff --git a/packages/svelte-table/tests/fixtures/SelectorHarness.svelte b/packages/svelte-table/tests/fixtures/SelectorHarness.svelte new file mode 100644 index 0000000000..2eb5be2435 --- /dev/null +++ b/packages/svelte-table/tests/fixtures/SelectorHarness.svelte @@ -0,0 +1,47 @@ + + +{String(selectedRow.current)} +{JSON.stringify(wholeSelection.current)} + + + + diff --git a/packages/svelte-table/tests/fixtures/SsrHarness.svelte b/packages/svelte-table/tests/fixtures/SsrHarness.svelte new file mode 100644 index 0000000000..994da785b3 --- /dev/null +++ b/packages/svelte-table/tests/fixtures/SsrHarness.svelte @@ -0,0 +1,57 @@ + + +{JSON.stringify(table.state.rowSelection)} + + + diff --git a/packages/svelte-table/tests/fixtures/hook-fixture.ts b/packages/svelte-table/tests/fixtures/hook-fixture.ts new file mode 100644 index 0000000000..85ed6f459d --- /dev/null +++ b/packages/svelte-table/tests/fixtures/hook-fixture.ts @@ -0,0 +1,16 @@ +import { stockFeatures } from '@tanstack/table-core' +import { createTableHook } from '../../src/createTableHook.svelte' +import CellBadge from './HookCellBadge.svelte' +import HeaderBadge from './HookHeaderBadge.svelte' +import TableBadge from './HookTableBadge.svelte' + +export type HookData = { id: string; title: string } + +export const hook = createTableHook({ + features: stockFeatures, + enableRowSelection: false, + getRowId: (row: HookData) => `row-${row.id}`, + tableComponents: { TableBadge }, + cellComponents: { CellBadge }, + headerComponents: { HeaderBadge }, +}) diff --git a/packages/svelte-table/tests/rendering.test.ts b/packages/svelte-table/tests/rendering.test.ts new file mode 100644 index 0000000000..4db5c2f4bb --- /dev/null +++ b/packages/svelte-table/tests/rendering.test.ts @@ -0,0 +1,234 @@ +// @vitest-environment jsdom + +import { describe, expect, test, vi } from 'vitest' +import { fireEvent, render, screen } from '@testing-library/svelte' +import { stockFeatures } from '@tanstack/table-core' +import { renderComponent } from '../src/render-component' +import ContextFailure from './fixtures/ContextFailure.svelte' +import FlexRenderHarness from './fixtures/FlexRenderHarness.svelte' +import HookHarness from './fixtures/HookHarness.svelte' +import RenderBadge from './fixtures/RenderBadge.svelte' +import { hook } from './fixtures/hook-fixture' +import type { AppSvelteTable } from '../src/createTableHook.svelte' + +function outputText(name: string) { + return screen.getByRole('status', { name }).textContent +} + +function createCell( + renderer: (context: { value: string }) => unknown, + value: string, + modes: { + aggregatedRenderer?: (context: { value: string }) => unknown + aggregated?: boolean + placeholder?: boolean + } = {}, +) { + return { + column: { + columnDef: { + cell: renderer, + aggregatedCell: modes.aggregatedRenderer, + }, + }, + getContext: () => ({ value }), + getIsAggregated: () => modes.aggregated ?? false, + getIsPlaceholder: () => modes.placeholder ?? false, + } +} + +describe('FlexRender', () => { + test('supports cell modes, header/footer shorthand, legacy props, and components', () => { + const normalRenderer = vi.fn( + (context: { value: string }) => `cell:${context.value}`, + ) + const aggregatedRenderer = vi.fn( + (context: { value: string }) => `aggregate:${context.value}`, + ) + const placeholderRenderer = vi.fn(() => 'should-not-render') + const headerRenderer = vi.fn( + (context: { value: string }) => `header:${context.value}`, + ) + const footerRenderer = vi.fn( + (context: { value: string }) => `footer:${context.value}`, + ) + const legacyContent = vi.fn( + (context: { value: string }) => `legacy:${context.value}`, + ) + const componentRenderer = vi.fn((context: { value: string }) => + renderComponent(RenderBadge, { + label: `component:${context.value}`, + }), + ) + const normalCell = createCell(normalRenderer, 'Ada') + const aggregatedCell = createCell(normalRenderer, 'Ada', { + aggregated: true, + aggregatedRenderer, + }) + const placeholderCell = createCell(placeholderRenderer, 'Ada', { + placeholder: true, + }) + const header = { + column: { columnDef: { header: headerRenderer } }, + getContext: () => ({ value: 'Name' }), + } + const footer = { + column: { columnDef: { footer: footerRenderer } }, + getContext: () => ({ value: 'Total' }), + } + const componentCell = createCell(componentRenderer, 'Ada') + + render(FlexRenderHarness, { + normalCell, + aggregatedCell, + placeholderCell, + header, + footer, + legacyContent, + componentCell, + reactiveCell: normalCell, + }) + + expect(outputText('Normal cell')).toBe('cell:Ada') + expect(outputText('Aggregated cell')).toBe('aggregate:Ada') + expect(outputText('Placeholder cell')).toBe('') + expect(outputText('Header')).toBe('header:Name') + expect(outputText('Footer')).toBe('footer:Total') + expect(outputText('Static header')).toBe('Static header') + expect(outputText('Legacy render')).toBe('legacy:Legacy') + expect(outputText('Component render')).toBe('component:Ada') + expect(outputText('Snippet render')).toBe('snippet:Ada') + expect(normalRenderer).toHaveBeenCalled() + expect(aggregatedRenderer).toHaveBeenCalledWith({ value: 'Ada' }) + expect(placeholderRenderer).not.toHaveBeenCalled() + expect(headerRenderer).toHaveBeenCalledWith({ value: 'Name' }) + expect(footerRenderer).toHaveBeenCalledWith({ value: 'Total' }) + expect(legacyContent).toHaveBeenCalledWith({ value: 'Legacy' }) + }) + + test('reacts when a stable cell changes grouping mode', async () => { + const cell = createCell(() => '', '') + const header = { + column: { columnDef: { header: '' } }, + getContext: () => ({}), + } + const footer = { + column: { columnDef: { footer: '' } }, + getContext: () => ({}), + } + render(FlexRenderHarness, { + normalCell: cell, + aggregatedCell: cell, + placeholderCell: cell, + header, + footer, + legacyContent: () => '', + componentCell: cell, + reactiveCell: cell, + }) + + expect(outputText('Grouping cell')).toBe('cell:Grouped') + + await fireEvent.click( + screen.getByRole('button', { name: 'Show aggregate cell' }), + ) + expect(outputText('Grouping cell')).toBe('aggregate:Grouped') + + await fireEvent.click( + screen.getByRole('button', { name: 'Show placeholder cell' }), + ) + expect(outputText('Grouping cell')).toBe('') + + await fireEvent.click( + screen.getByRole('button', { name: 'Show normal cell' }), + ) + expect(outputText('Grouping cell')).toBe('cell:Grouped') + }) + + test('updates when a truthy cell prop is replaced', async () => { + const cell = createCell(() => '', '') + const header = { + column: { columnDef: { header: '' } }, + getContext: () => ({}), + } + const footer = { + column: { columnDef: { footer: '' } }, + getContext: () => ({}), + } + const reactiveCell = createCell((context) => `cell:${context.value}`, 'Ada') + const { rerender } = render(FlexRenderHarness, { + normalCell: cell, + aggregatedCell: cell, + placeholderCell: cell, + header, + footer, + legacyContent: () => '', + componentCell: cell, + reactiveCell, + }) + + expect(outputText('Reactive cell')).toBe('cell:Ada') + + await rerender({ + reactiveCell: createCell((context) => `cell:${context.value}`, 'Grace'), + }) + + expect(outputText('Reactive cell')).toBe('cell:Grace') + }) +}) + +describe('createTableHook', () => { + test('binds defaults, wrapper components, contexts, and live render helpers', async () => { + const tableCaptor = + vi.fn<(table: AppSvelteTable) => void>() + render(HookHarness, { tableCaptor }) + + const table = tableCaptor.mock.lastCall?.[0] + + expect(hook.appFeatures).toBe(stockFeatures) + expect(table?.getRowModel().rows[0]?.id).toBe('row-1') + expect(table?.TableBadge).toEqual(expect.any(Function)) + expect(table?.AppTable).toEqual(expect.any(Function)) + expect(table?.AppCell).toEqual(expect.any(Function)) + expect(table?.AppHeader).toEqual(expect.any(Function)) + expect(table?.AppFooter).toEqual(expect.any(Function)) + expect(table?.FlexRender).toEqual(expect.any(Function)) + expect(outputText('Hook row can be selected')).toBe('true') + expect(screen.getByText('table-component:row-1').textContent).toBe( + 'table-component:row-1', + ) + expect(screen.getByText('cell-component:First').textContent).toBe( + 'cell-component:First', + ) + expect(screen.getByText('header-component:title').textContent).toBe( + 'header-component:title', + ) + expect(outputText('Hook cell')).toBe('cell:First') + expect(outputText('Hook header')).toBe('header:title') + expect(outputText('Hook footer')).toBe('footer:title') + + await fireEvent.click( + screen.getByRole('button', { name: 'Replace hook row and column' }), + ) + + expect(screen.getByText('cell-component:Second').textContent).toBe( + 'cell-component:Second', + ) + expect(screen.getByText('header-component:updated-title').textContent).toBe( + 'header-component:updated-title', + ) + expect(outputText('Hook cell')).toBe('updated-cell:Second') + expect(outputText('Hook header')).toBe('updated-header:updated-title') + expect(outputText('Hook footer')).toBe('updated-footer:updated-title') + }) + + test.each([ + ['useTableContext', () => hook.useTableContext()], + ['useCellContext', () => hook.useCellContext()], + ['useHeaderContext', () => hook.useHeaderContext()], + ])('%s throws a focused error outside its provider', (name, readContext) => { + expect(() => render(ContextFailure, { readContext })).toThrowError( + new RegExp(`\\\`${name}\\\` must be used within`), + ) + }) +}) diff --git a/packages/svelte-table/tests/ssr.test.ts b/packages/svelte-table/tests/ssr.test.ts new file mode 100644 index 0000000000..f01dce3c61 --- /dev/null +++ b/packages/svelte-table/tests/ssr.test.ts @@ -0,0 +1,16 @@ +// @vitest-environment node + +import { describe, expect, test } from 'vitest' +import { render } from 'svelte/server' +import SsrHarness from './fixtures/SsrHarness.svelte' + +describe('Svelte adapter SSR', () => { + test('renders table state and each FlexRender cell mode without a DOM', () => { + const { body } = render(SsrHarness) + + expect(body).toContain('{"1":true}') + expect(body).toContain('cell:Ada') + expect(body).toContain('aggregate:Ada') + expect(body).not.toContain('should-not-render') + }) +}) diff --git a/packages/svelte-table/tests/test-setup.ts b/packages/svelte-table/tests/test-setup.ts new file mode 100644 index 0000000000..0b9023771e --- /dev/null +++ b/packages/svelte-table/tests/test-setup.ts @@ -0,0 +1 @@ +import '@testing-library/svelte/vitest' diff --git a/packages/svelte-table/tsconfig.json b/packages/svelte-table/tsconfig.json index ed9b7835dd..d69d19f8e1 100644 --- a/packages/svelte-table/tsconfig.json +++ b/packages/svelte-table/tsconfig.json @@ -1,8 +1,4 @@ { "extends": "../../tsconfig.json", - "compilerOptions": { - "rootDir": "./src", - "outDir": "./build/lib" - }, - "include": ["src"] + "include": ["src", "tests", "eslint.config.js", "vite.config.ts"] } diff --git a/packages/svelte-table/vite.config.ts b/packages/svelte-table/vite.config.ts index 3d696d6ec5..21c2d798b0 100644 --- a/packages/svelte-table/vite.config.ts +++ b/packages/svelte-table/vite.config.ts @@ -3,4 +3,10 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ plugins: [svelte()], + resolve: { + conditions: ['browser'], + }, + test: { + setupFiles: ['./tests/test-setup.ts'], + }, }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a9e73d8496..a96e56b0e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13262,6 +13262,9 @@ importers: specifier: workspace:* version: link:../table-core devDependencies: + '@testing-library/dom': + specifier: ^10.4.1 + version: 10.4.1 '@types/alpinejs': specifier: ^3.13.11 version: 3.13.11 @@ -13437,6 +13440,9 @@ importers: '@lit/context': specifier: ^1.1.6 version: 1.1.6 + '@testing-library/dom': + specifier: ^10.4.1 + version: 10.4.1 lit: specifier: ^3.3.3 version: 3.3.3 @@ -13459,9 +13465,15 @@ importers: '@preact/preset-vite': specifier: ^2.10.5 version: 2.10.5(@babel/core@7.29.7)(preact@10.29.2)(rollup@4.61.1)(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)) + '@testing-library/preact': + specifier: ^3.2.4 + version: 3.2.4(preact@10.29.2) preact: specifier: ^10.29.2 version: 10.29.2 + preact-render-to-string: + specifier: ^6.7.0 + version: 6.7.0(preact@10.29.2) packages/preact-table-devtools: dependencies: @@ -13612,6 +13624,9 @@ importers: '@sveltejs/vite-plugin-svelte': specifier: ^7.1.2 version: 7.1.2(svelte@5.56.2(@typescript-eslint/types@8.62.0))(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.15))(terser@5.46.2)(yaml@2.9.0)) + '@testing-library/svelte': + specifier: ^5.4.2 + version: 5.4.2(svelte@5.56.2(@typescript-eslint/types@8.62.0)) eslint-plugin-svelte: specifier: ^3.19.0 version: 3.19.0(eslint@10.5.0(jiti@2.7.0))(svelte@5.56.2(@typescript-eslint/types@8.62.0)) @@ -19590,6 +19605,10 @@ packages: resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} + '@testing-library/dom@8.20.1': + resolution: {integrity: sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g==} + engines: {node: '>=12'} + '@testing-library/dom@9.3.4': resolution: {integrity: sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==} engines: {node: '>=14'} @@ -19598,6 +19617,12 @@ packages: resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + '@testing-library/preact@3.2.4': + resolution: {integrity: sha512-F+kJ243LP6VmEK1M809unzTE/ijg+bsMNuiRN0JEDIJBELKKDNhdgC/WrUSZ7klwJvtlO3wQZ9ix+jhObG07Fg==} + engines: {node: '>= 12'} + peerDependencies: + preact: '>=10 || ^10.0.0-alpha.0 || ^10.0.0-beta.0' + '@testing-library/react@16.3.2': resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} engines: {node: '>=18'} @@ -19613,6 +19638,25 @@ packages: '@types/react-dom': optional: true + '@testing-library/svelte-core@1.1.3': + resolution: {integrity: sha512-KkMAvXeWorxN2Yn0kdC1lfoAItxpoj4uOWzxK5leDrNxonLvS5nwBFvztrroyTszQ0Wf/EU6iLT8JhY5qcn22g==} + engines: {node: '>=16'} + peerDependencies: + svelte: ^3 || ^4 || ^5 || ^5.0.0-next.0 + + '@testing-library/svelte@5.4.2': + resolution: {integrity: sha512-4o31E4HGo5BU5KwPkulNRocEden+7Tt9JYm9uhln5ajF7DULeyFA46BBWVfKJ8Ms9B3JmOFPTIiVamH7n3KpuQ==} + engines: {node: '>= 10'} + peerDependencies: + svelte: ^3 || ^4 || ^5 || ^5.0.0-next.0 + vite: '*' + vitest: '*' + peerDependenciesMeta: + vite: + optional: true + vitest: + optional: true + '@testing-library/vue@8.1.0': resolution: {integrity: sha512-ls4RiHO1ta4mxqqajWRh8158uFObVrrtAPoxk7cIp4HrnQUj/ScKzqz53HxYpG3X6Zb7H2v+0eTGLSoy8HQ2nA==} engines: {node: '>=14'} @@ -24300,6 +24344,11 @@ packages: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} + preact-render-to-string@6.7.0: + resolution: {integrity: sha512-Z4WR8fmLMRpdYqJ9i7vrlXSsSrxVJydwrkEXHapexfARbWfGb7vGcnvNQnIzN0cXciMVOlz/XLoiMCi9gUsy9Q==} + peerDependencies: + preact: '>=10 || >= 11.0.0-0' + preact@10.29.2: resolution: {integrity: sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==} @@ -33351,6 +33400,17 @@ snapshots: picocolors: 1.1.1 pretty-format: 27.5.1 + '@testing-library/dom@8.20.1': + 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/dom@9.3.4': dependencies: '@babel/code-frame': 7.29.7 @@ -33371,6 +33431,11 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 + '@testing-library/preact@3.2.4(preact@10.29.2)': + dependencies: + '@testing-library/dom': 8.20.1 + preact: 10.29.2 + '@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 @@ -33381,6 +33446,16 @@ snapshots: '@types/react': 19.2.16 '@types/react-dom': 19.2.3(@types/react@19.2.16) + '@testing-library/svelte-core@1.1.3(svelte@5.56.2(@typescript-eslint/types@8.62.0))': + dependencies: + svelte: 5.56.2(@typescript-eslint/types@8.62.0) + + '@testing-library/svelte@5.4.2(svelte@5.56.2(@typescript-eslint/types@8.62.0))': + dependencies: + '@testing-library/dom': 10.4.1 + '@testing-library/svelte-core': 1.1.3(svelte@5.56.2(@typescript-eslint/types@8.62.0)) + svelte: 5.56.2(@typescript-eslint/types@8.62.0) + '@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 @@ -39381,6 +39456,10 @@ snapshots: powershell-utils@0.1.0: {} + preact-render-to-string@6.7.0(preact@10.29.2): + dependencies: + preact: 10.29.2 + preact@10.29.2: {} prelude-ls@1.2.1: {} From cc3c1803f6755a7a5cd7689d80731ecdc58de7c2 Mon Sep 17 00:00:00 2001 From: riccardoperra Date: Thu, 30 Jul 2026 00:18:36 +0200 Subject: [PATCH 2/3] add test for alpine, ember, lit, preact, svelte --- packages/ember-table/src/use-table.ts | 24 +-- .../tests/integration/external-state.test.gts | 4 +- .../src/createTableHook.svelte.ts | 199 +++++------------- packages/svelte-table/src/index.ts | 1 - .../tests/fixtures/HookHarness.svelte | 36 +--- packages/svelte-table/tests/rendering.test.ts | 16 +- 6 files changed, 69 insertions(+), 211 deletions(-) diff --git a/packages/ember-table/src/use-table.ts b/packages/ember-table/src/use-table.ts index 6630fd29a6..e1d02b9cc9 100644 --- a/packages/ember-table/src/use-table.ts +++ b/packages/ember-table/src/use-table.ts @@ -102,29 +102,7 @@ export function useTable< } }) - const getLiveOptions = () => { - const options = liveOptions.get() - const controlledState = options.state as Record | undefined - - // Keep the writable fallback aligned with every controlled value that has - // been observed. This makes a later ownership release expose the latest - // controlled snapshot and makes functional table updaters start from that - // snapshot after control is reacquired. - if (controlledState) { - untrack(() => { - for (const key in controlledState) { - const baseAtom = table.baseAtoms[key] - const controlledValue = controlledState[key] - - if (baseAtom && baseAtom.get() !== controlledValue) { - baseAtom.set(() => controlledValue) - } - } - }) - } - - return options - } + const getLiveOptions = () => liveOptions.get() /** * This is to get around core table not using lazy access so we need to re-wrap diff --git a/packages/ember-table/tests/integration/external-state.test.gts b/packages/ember-table/tests/integration/external-state.test.gts index ed8a50f102..56fd97eb33 100644 --- a/packages/ember-table/tests/integration/external-state.test.gts +++ b/packages/ember-table/tests/integration/external-state.test.gts @@ -316,8 +316,8 @@ module('Integration | external state (controlled)', function (hooks) { assert .dom('[role="status"][aria-label="Page index"]') .hasText( - '1', - 'a second release exposes the newest internal write from the reacquired value', + '4', + 'a second release exposes the independently updated internal fallback', ) }) diff --git a/packages/svelte-table/src/createTableHook.svelte.ts b/packages/svelte-table/src/createTableHook.svelte.ts index afe06809e0..36e9417be4 100644 --- a/packages/svelte-table/src/createTableHook.svelte.ts +++ b/packages/svelte-table/src/createTableHook.svelte.ts @@ -38,50 +38,6 @@ import type { } from '@tanstack/table-core' export type ComponentType> = Component -export type BoundFlexRenderComponent = Component> - -function createLiveView( - getCurrent: () => TCurrent, - extensions: TExtensions, -): TCurrent & TExtensions { - const hasExtension = (key: PropertyKey) => - Object.prototype.hasOwnProperty.call(extensions, key) - - return new Proxy({} as TCurrent & TExtensions, { - get: (_target, key) => { - if (hasExtension(key)) { - return Reflect.get(extensions, key, extensions) - } - const current = getCurrent() - return Reflect.get(current, key, current) - }, - getOwnPropertyDescriptor: (_target, key) => { - const source = hasExtension(key) ? extensions : getCurrent() - const descriptor = Reflect.getOwnPropertyDescriptor(source, key) - if (!descriptor) return undefined - - return { - configurable: true, - enumerable: descriptor.enumerable, - get: () => Reflect.get(source, key, source), - set: (value) => { - Reflect.set(source, key, value, source) - }, - } - }, - has: (_target, key) => hasExtension(key) || Reflect.has(getCurrent(), key), - ownKeys: () => [ - ...new Set([ - ...Reflect.ownKeys(getCurrent()), - ...Reflect.ownKeys(extensions), - ]), - ], - set: (_target, key, value) => { - const source = hasExtension(key) ? extensions : getCurrent() - return Reflect.set(source, key, value, source) - }, - }) -} // ============================================================================= // Enhanced Context Types with Pre-bound Components @@ -98,7 +54,7 @@ export type AppCellContext< TCellComponents extends Record>, > = { cell: Cell & - TCellComponents & { FlexRender: BoundFlexRenderComponent } + TCellComponents & { FlexRender: typeof FlexRenderSvelte } column: Column getValue: CellContext['getValue'] renderValue: CellContext['renderValue'] @@ -118,7 +74,7 @@ export type AppHeaderContext< > = { column: Column header: Header & - THeaderComponents & { FlexRender: BoundFlexRenderComponent } + THeaderComponents & { FlexRender: typeof FlexRenderSvelte } table: Table } @@ -366,9 +322,7 @@ export type AppSvelteTable< children: Snippet< [ Cell & - NoInfer & { - FlexRender: BoundFlexRenderComponent - }, + NoInfer & { FlexRender: typeof FlexRenderSvelte }, ] > }> @@ -389,7 +343,7 @@ export type AppSvelteTable< [ Header & NoInfer & { - FlexRender: BoundFlexRenderComponent + FlexRender: typeof FlexRenderSvelte }, ] > @@ -411,7 +365,7 @@ export type AppSvelteTable< [ Header & NoInfer & { - FlexRender: BoundFlexRenderComponent + FlexRender: typeof FlexRenderSvelte }, ] > @@ -486,7 +440,7 @@ export interface CreateTableHookResult< any, TValue > & - TCellComponents & { FlexRender: BoundFlexRenderComponent } + TCellComponents & { FlexRender: typeof FlexRenderSvelte } /** * Reads the header provided by the nearest `` / * ``, extended with your `headerComponents` and a @@ -497,7 +451,7 @@ export interface CreateTableHookResult< any, TValue > & - THeaderComponents & { FlexRender: BoundFlexRenderComponent } + THeaderComponents & { FlexRender: typeof FlexRenderSvelte } } // ============================================================================= @@ -627,7 +581,7 @@ export function createTableHook< any, TValue > & - TCellComponents & { FlexRender: BoundFlexRenderComponent } { + TCellComponents & { FlexRender: typeof FlexRenderSvelte } { const cell = getContext(cellContextKey) if (!cell) { @@ -637,11 +591,11 @@ export function createTableHook< ) } - // `` provides a live view of the current cell and its bound - // components, so same-key row-model replacements stay current without - // remounting context consumers. + // `` Object.assign-es `cellComponents` and `FlexRender` onto + // the same cell instance it puts in context, so this asserts the runtime + // shape. return cell as unknown as Cell & - TCellComponents & { FlexRender: BoundFlexRenderComponent } + TCellComponents & { FlexRender: typeof FlexRenderSvelte } } /** @@ -654,7 +608,7 @@ export function createTableHook< any, TValue > & - THeaderComponents & { FlexRender: BoundFlexRenderComponent } { + THeaderComponents & { FlexRender: typeof FlexRenderSvelte } { const header = getContext(headerContextKey) if (!header) { @@ -663,10 +617,10 @@ export function createTableHook< ) } - // `` / `` provide a live view of the - // current header and its bound components. + // `` / `` Object.assign `headerComponents` + // and `FlexRender` onto the same header instance they put in context. return header as unknown as Header & - THeaderComponents & { FlexRender: BoundFlexRenderComponent } + THeaderComponents & { FlexRender: typeof FlexRenderSvelte } } /** @@ -703,103 +657,60 @@ export function createTableHook< selector, ) - // Create wrapper components using the svelte-form (internal, props) => - // pattern. Svelte keeps the props object live through getter-backed - // properties, so preserve it instead of destructuring or spreading it. - // Context consumers receive a getter-backed view for the same reason: - // row-model refreshes can replace a cell/header without remounting the - // wrapper component. + // Build cellComponents with FlexRender included + const cellComponentsWithFlexRender = { + FlexRender: FlexRenderSvelte, + ...(cellComponents ?? {}), + } + + // Build headerComponents with FlexRender included + const headerComponentsWithFlexRender = { + FlexRender: FlexRenderSvelte, + ...(headerComponents ?? {}), + } + + // Create wrapper components using the svelte-form (internal, props) => pattern. + // setContext is called in the closure — this runs during component + // initialization, so Svelte's context API works correctly. + // With keyed {#each} blocks, components are recreated on reorder, + // so context is always fresh. const AppTable = ((internal: any, props: any) => { setContext(tableContextKey, table) - return AppTableSvelte(internal, props) + return AppTableSvelte(internal, { ...props }) }) as Component<{ children: Snippet }> - const AppCell = ((internal: any, props: any) => { - const BoundFlexRender = ((componentInternal: any, renderProps: any) => { - return FlexRenderSvelte( - componentInternal, - mergeObjects(renderProps, { - get cell() { - return props.cell - }, - }), - ) - }) as BoundFlexRenderComponent - const boundComponents = { - FlexRender: BoundFlexRender, - ...(cellComponents ?? {}), - } - const liveCell = createLiveView(() => props.cell, boundComponents) - - setContext(cellContextKey, liveCell) - - return AppCellSvelte( - internal, - mergeObjects(props, { - cellComponents: boundComponents, - }), - ) + const AppCell = ((internal: any, { children, cell }: any) => { + setContext(cellContextKey, cell) + return AppCellSvelte(internal, { + cell, + cellComponents: cellComponentsWithFlexRender, + children, + }) }) as Component<{ cell: Cell children: Snippet<[any]> }> - const AppHeader = ((internal: any, props: any) => { - const BoundFlexRender = ((componentInternal: any, renderProps: any) => { - return FlexRenderSvelte( - componentInternal, - mergeObjects(renderProps, { - get header() { - return props.header - }, - }), - ) - }) as BoundFlexRenderComponent - const boundComponents = { - FlexRender: BoundFlexRender, - ...(headerComponents ?? {}), - } - const liveHeader = createLiveView(() => props.header, boundComponents) - - setContext(headerContextKey, liveHeader) - - return AppHeaderSvelte( - internal, - mergeObjects(props, { - headerComponents: boundComponents, - }), - ) + const AppHeader = ((internal: any, { children, header }: any) => { + setContext(headerContextKey, header) + return AppHeaderSvelte(internal, { + header, + headerComponents: headerComponentsWithFlexRender, + children, + }) }) as Component<{ header: Header children: Snippet<[any]> }> // AppFooter reuses AppHeaderSvelte (footers use Header type in table-core) - const AppFooter = ((internal: any, props: any) => { - const BoundFlexRender = ((componentInternal: any, renderProps: any) => { - return FlexRenderSvelte( - componentInternal, - mergeObjects(renderProps, { - get footer() { - return props.header - }, - }), - ) - }) as BoundFlexRenderComponent - const boundComponents = { - FlexRender: BoundFlexRender, - ...(headerComponents ?? {}), - } - const liveHeader = createLiveView(() => props.header, boundComponents) - - setContext(headerContextKey, liveHeader) - - return AppHeaderSvelte( - internal, - mergeObjects(props, { - headerComponents: boundComponents, - }), - ) + const AppFooter = ((internal: any, { children, header }: any) => { + setContext(headerContextKey, header) + return AppHeaderSvelte(internal, { + header, + headerComponents: headerComponentsWithFlexRender, + children, + }) }) as Component<{ header: Header children: Snippet<[any]> diff --git a/packages/svelte-table/src/index.ts b/packages/svelte-table/src/index.ts index 8ef8f34d54..75c8ab8fbf 100644 --- a/packages/svelte-table/src/index.ts +++ b/packages/svelte-table/src/index.ts @@ -12,7 +12,6 @@ export type { AppGroupColumnDef, AppHeaderContext, AppSvelteTable, - BoundFlexRenderComponent, ComponentType, CreateTableHookOptions, CreateTableHookResult, diff --git a/packages/svelte-table/tests/fixtures/HookHarness.svelte b/packages/svelte-table/tests/fixtures/HookHarness.svelte index 6514069f6d..896cac7204 100644 --- a/packages/svelte-table/tests/fixtures/HookHarness.svelte +++ b/packages/svelte-table/tests/fixtures/HookHarness.svelte @@ -18,23 +18,10 @@ footer: ({ column }) => `footer:${column.id}`, }), ]) - const updatedColumns = columnHelper.columns([ - columnHelper.accessor('title', { - id: 'updated-title', - header: ({ column }) => `updated-header:${column.id}`, - cell: ({ getValue }) => `updated-cell:${getValue()}`, - footer: ({ column }) => `updated-footer:${column.id}`, - }), - ]) - let data = $state>([{ id: '1', title: 'First' }]) - let columns = $state(initialColumns) + const data: Array = [{ id: '1', title: 'First' }] const table = hook.createAppTable({ - get data() { - return data - }, - get columns() { - return columns - }, + data, + columns: initialColumns, enableRowSelection: true, }) const row = $derived(table.getRowModel().rows[0]!) @@ -43,11 +30,6 @@ const footer = $derived(table.getFooterGroups()[0]!.headers[0]!) untrack(() => tableCaptor?.(table)) - - function replaceRowAndColumn() { - data = [{ id: '1', title: 'Second' }] - columns = updatedColumns - } @@ -59,22 +41,24 @@ {#snippet children(value)} - + {/snippet} {#snippet children(value)} - + {/snippet} {#snippet children(value)} - + {/snippet} - - diff --git a/packages/svelte-table/tests/rendering.test.ts b/packages/svelte-table/tests/rendering.test.ts index 4db5c2f4bb..033fdbc68e 100644 --- a/packages/svelte-table/tests/rendering.test.ts +++ b/packages/svelte-table/tests/rendering.test.ts @@ -178,7 +178,7 @@ describe('FlexRender', () => { }) describe('createTableHook', () => { - test('binds defaults, wrapper components, contexts, and live render helpers', async () => { + test('binds defaults, wrapper components, contexts, and render helpers', () => { const tableCaptor = vi.fn<(table: AppSvelteTable) => void>() render(HookHarness, { tableCaptor }) @@ -206,20 +206,6 @@ describe('createTableHook', () => { expect(outputText('Hook cell')).toBe('cell:First') expect(outputText('Hook header')).toBe('header:title') expect(outputText('Hook footer')).toBe('footer:title') - - await fireEvent.click( - screen.getByRole('button', { name: 'Replace hook row and column' }), - ) - - expect(screen.getByText('cell-component:Second').textContent).toBe( - 'cell-component:Second', - ) - expect(screen.getByText('header-component:updated-title').textContent).toBe( - 'header-component:updated-title', - ) - expect(outputText('Hook cell')).toBe('updated-cell:Second') - expect(outputText('Hook header')).toBe('updated-header:updated-title') - expect(outputText('Hook footer')).toBe('updated-footer:updated-title') }) test.each([ From e8bf34fd7085dfdfc17b63d17ef1be054e10579a Mon Sep 17 00:00:00 2001 From: riccardoperra Date: Thu, 30 Jul 2026 00:21:36 +0200 Subject: [PATCH 3/3] revert some svelte adapter updates for test coverage --- .../svelte-table/src/reactivity.svelte.ts | 25 ++++------ .../tests/adapter-lifecycle.test.ts | 34 ------------- .../tests/fixtures/ReactivityHarness.svelte | 50 +++++++------------ 3 files changed, 27 insertions(+), 82 deletions(-) diff --git a/packages/svelte-table/src/reactivity.svelte.ts b/packages/svelte-table/src/reactivity.svelte.ts index 22648c714b..bfe5bef784 100644 --- a/packages/svelte-table/src/reactivity.svelte.ts +++ b/packages/svelte-table/src/reactivity.svelte.ts @@ -32,26 +32,19 @@ function subscribeToRune( } function createRuneWritableAtom(initialValue: T): Atom { - const storeAtom = createAtom(initialValue) - let version = $state(0) + let value = $state(initialValue) return { set: (updater: T | ((prevVal: T) => T)) => { - const previous = storeAtom.get() - if (typeof updater === 'function') { - storeAtom.set(updater as (prevVal: T) => T) - } else { - storeAtom.set(updater) - } - if (!Object.is(previous, storeAtom.get())) { - version += 1 - } - }, - get: () => { - version - return storeAtom.get() + value = + typeof updater === 'function' + ? (updater as (prevVal: T) => T)(value) + : updater }, - subscribe: storeAtom.subscribe, + get: () => value, + subscribe: ((observerOrNext: Observer | ((value: T) => void)) => { + return subscribeToRune(() => value, observerOrNext) + }) as Atom['subscribe'], } } diff --git a/packages/svelte-table/tests/adapter-lifecycle.test.ts b/packages/svelte-table/tests/adapter-lifecycle.test.ts index d67432d4ee..fce850d689 100644 --- a/packages/svelte-table/tests/adapter-lifecycle.test.ts +++ b/packages/svelte-table/tests/adapter-lifecycle.test.ts @@ -48,40 +48,6 @@ describe('Svelte adapter lifecycle and reactive options', () => { }) }) - test('controlled state can release and reacquire ownership without losing the latest value', async () => { - render(ReactivityHarness) - - expect(outputText('Selected rows')).toBe('{"1":true}') - expect(outputText('Selected count')).toBe('1') - - await fireEvent.click( - screen.getByRole('button', { name: 'Release selection ownership' }), - ) - expect(outputText('Selected rows')).toBe('{"1":true}') - - await fireEvent.click( - screen.getByRole('button', { name: 'Select second row' }), - ) - expect(outputText('Selected rows')).toBe('{"2":true}') - - await fireEvent.click( - screen.getByRole('button', { name: 'Control both rows' }), - ) - expect(outputText('Selected rows')).toBe('{"1":true,"2":true}') - expect(outputText('Selected count')).toBe('2') - - await fireEvent.click( - screen.getByRole('button', { name: 'Clear selection through table' }), - ) - expect(outputText('Selected rows')).toBe('{"1":true,"2":true}') - - await fireEvent.click( - screen.getByRole('button', { name: 'Release selection ownership' }), - ) - expect(outputText('Selected rows')).toBe('{}') - expect(outputText('Selected count')).toBe('0') - }) - test('external atoms take precedence over controlled state and receive table updates', async () => { const externalRowSelection = createAtom({ 2: true }) render(ReactivityHarness, { externalRowSelection }) diff --git a/packages/svelte-table/tests/fixtures/ReactivityHarness.svelte b/packages/svelte-table/tests/fixtures/ReactivityHarness.svelte index b72d1a07c8..112565dc16 100644 --- a/packages/svelte-table/tests/fixtures/ReactivityHarness.svelte +++ b/packages/svelte-table/tests/fixtures/ReactivityHarness.svelte @@ -54,30 +54,25 @@ let controlledState = $state<{ rowSelection?: RowSelectionState }>({ rowSelection: { 1: true }, }) - const table = createTable( - { - get data() { - return data - }, - get columns() { - return columns - }, - features, - getRowId: (row) => row.id, - get enableRowSelection() { - return enableRowSelection - }, - get state() { - return controlledState - }, - atoms: externalSelectionAtom - ? { rowSelection: externalSelectionAtom } - : undefined, + const table = createTable({ + get data() { + return data }, - (state) => ({ - selectedCount: Object.keys(state.rowSelection).length, - }), - ) + get columns() { + return columns + }, + features, + getRowId: (row) => row.id, + get enableRowSelection() { + return enableRowSelection + }, + get state() { + return controlledState + }, + atoms: externalSelectionAtom + ? { rowSelection: externalSelectionAtom } + : undefined, + }) const selected = subscribeTable(table.atoms.rowSelection) const lifecycleSelection = externalSelectionAtom ? subscribeTable(externalSelectionAtom) @@ -99,10 +94,6 @@ }) }) - function releaseOwnership() { - controlledState = {} - } - function controlBothRows() { controlledState = { rowSelection: { 1: true, 2: true } } } @@ -117,7 +108,6 @@ {JSON.stringify(selected.current)} -{table.state.selectedCount} {table .getRowModel() @@ -141,12 +131,8 @@ >{String(table.getRowModel().rows[0]?.getCanSelect() ?? false)} - -