diff --git a/docs/framework/alpine/guide/custom-features.md b/docs/framework/alpine/guide/custom-features.md index 5d0ad25fcd..8ae8101f19 100644 --- a/docs/framework/alpine/guide/custom-features.md +++ b/docs/framework/alpine/guide/custom-features.md @@ -140,7 +140,7 @@ The `getInitialState` method in a table feature is responsible for setting the d #### initTableInstanceData and resetTableInstanceData -Use `initTableInstanceData` for mutable, non-reactive data that belongs to one table instance, such as an interaction anchor or an imperative cache. It runs once after table options, state atoms, and the store have been created. Every feature's initialization hook runs before any feature's `constructTableAPIs` hook. +Use `initTableInstanceData` for mutable, non-reactive data that belongs to one table instance, such as an interaction anchor or an imperative cache. It runs once after table options, state atoms, and the store have been created. Features are processed in a single pass in registration order; each feature's initialization hook runs just before that feature's `constructTableAPIs` hook, so hooks may rely on data and APIs from features registered earlier. Use `resetTableInstanceData` to clear that transient data when `table.reset()` runs. Reset hooks run after internally owned table state atoms have been restored to `table.initialState`. They do not reset table state slices or externally controlled state, and `table.reset()` does not rerun `initTableInstanceData`. diff --git a/docs/framework/angular/guide/custom-features.md b/docs/framework/angular/guide/custom-features.md index 50d1fe2e27..5bbfe347ae 100644 --- a/docs/framework/angular/guide/custom-features.md +++ b/docs/framework/angular/guide/custom-features.md @@ -146,7 +146,7 @@ The `getInitialState` method in a table feature is responsible for setting the d ##### initTableInstanceData and resetTableInstanceData -Use `initTableInstanceData` for mutable, non-reactive data that belongs to one table instance, such as an interaction anchor or an imperative cache. It runs once after table options, state atoms, and the store have been created. Every feature's initialization hook runs before any feature's `constructTableAPIs` hook. +Use `initTableInstanceData` for mutable, non-reactive data that belongs to one table instance, such as an interaction anchor or an imperative cache. It runs once after table options, state atoms, and the store have been created. Features are processed in a single pass in registration order; each feature's initialization hook runs just before that feature's `constructTableAPIs` hook, so hooks may rely on data and APIs from features registered earlier. Use `resetTableInstanceData` to clear that transient data when `table.reset()` runs. Reset hooks run after internally owned table state atoms have been restored to `table.initialState`. They do not reset table state slices or externally controlled state, and `table.reset()` does not rerun `initTableInstanceData`. diff --git a/docs/framework/ember/guide/custom-features.md b/docs/framework/ember/guide/custom-features.md index e303d4b6dc..3141d67095 100644 --- a/docs/framework/ember/guide/custom-features.md +++ b/docs/framework/ember/guide/custom-features.md @@ -146,7 +146,7 @@ The `getInitialState` method in a table feature is responsible for setting the d ##### initTableInstanceData and resetTableInstanceData -Use `initTableInstanceData` for mutable, non-reactive data that belongs to one table instance, such as an interaction anchor or an imperative cache. It runs once after table options, state atoms, and the store have been created. Every feature's initialization hook runs before any feature's `constructTableAPIs` hook. +Use `initTableInstanceData` for mutable, non-reactive data that belongs to one table instance, such as an interaction anchor or an imperative cache. It runs once after table options, state atoms, and the store have been created. Features are processed in a single pass in registration order; each feature's initialization hook runs just before that feature's `constructTableAPIs` hook, so hooks may rely on data and APIs from features registered earlier. Use `resetTableInstanceData` to clear that transient data when `table.reset()` runs. Reset hooks run after internally owned table state atoms have been restored to `table.initialState`. They do not reset table state slices or externally controlled state, and `table.reset()` does not rerun `initTableInstanceData`. diff --git a/docs/framework/lit/guide/custom-features.md b/docs/framework/lit/guide/custom-features.md index 230212a129..ff65c2f161 100644 --- a/docs/framework/lit/guide/custom-features.md +++ b/docs/framework/lit/guide/custom-features.md @@ -138,7 +138,7 @@ The `getInitialState` method in a table feature is responsible for setting the d #### initTableInstanceData and resetTableInstanceData -Use `initTableInstanceData` for mutable, non-reactive data that belongs to one table instance, such as an interaction anchor or an imperative cache. It runs once after table options, state atoms, and the store have been created. Every feature's initialization hook runs before any feature's `constructTableAPIs` hook. +Use `initTableInstanceData` for mutable, non-reactive data that belongs to one table instance, such as an interaction anchor or an imperative cache. It runs once after table options, state atoms, and the store have been created. Features are processed in a single pass in registration order; each feature's initialization hook runs just before that feature's `constructTableAPIs` hook, so hooks may rely on data and APIs from features registered earlier. Use `resetTableInstanceData` to clear that transient data when `table.reset()` runs. Reset hooks run after internally owned table state atoms have been restored to `table.initialState`. They do not reset table state slices or externally controlled state, and `table.reset()` does not rerun `initTableInstanceData`. diff --git a/docs/framework/preact/guide/custom-features.md b/docs/framework/preact/guide/custom-features.md index bce4072fb1..536c940018 100644 --- a/docs/framework/preact/guide/custom-features.md +++ b/docs/framework/preact/guide/custom-features.md @@ -146,7 +146,7 @@ The `getInitialState` method in a table feature is responsible for setting the d ##### initTableInstanceData and resetTableInstanceData -Use `initTableInstanceData` for mutable, non-reactive data that belongs to one table instance, such as an interaction anchor or an imperative cache. It runs once after table options, state atoms, and the store have been created. Every feature's initialization hook runs before any feature's `constructTableAPIs` hook. +Use `initTableInstanceData` for mutable, non-reactive data that belongs to one table instance, such as an interaction anchor or an imperative cache. It runs once after table options, state atoms, and the store have been created. Features are processed in a single pass in registration order; each feature's initialization hook runs just before that feature's `constructTableAPIs` hook, so hooks may rely on data and APIs from features registered earlier. Use `resetTableInstanceData` to clear that transient data when `table.reset()` runs. Reset hooks run after internally owned table state atoms have been restored to `table.initialState`. They do not reset table state slices or externally controlled state, and `table.reset()` does not rerun `initTableInstanceData`. diff --git a/docs/framework/react/guide/custom-features.md b/docs/framework/react/guide/custom-features.md index 42aad2fb41..928547c2a4 100644 --- a/docs/framework/react/guide/custom-features.md +++ b/docs/framework/react/guide/custom-features.md @@ -146,7 +146,7 @@ The `getInitialState` method in a table feature is responsible for setting the d ##### initTableInstanceData and resetTableInstanceData -Use `initTableInstanceData` for mutable, non-reactive data that belongs to one table instance, such as an interaction anchor or an imperative cache. It runs once after table options, state atoms, and the store have been created. Every feature's initialization hook runs before any feature's `constructTableAPIs` hook. +Use `initTableInstanceData` for mutable, non-reactive data that belongs to one table instance, such as an interaction anchor or an imperative cache. It runs once after table options, state atoms, and the store have been created. Features are processed in a single pass in registration order; each feature's initialization hook runs just before that feature's `constructTableAPIs` hook, so hooks may rely on data and APIs from features registered earlier. Use `resetTableInstanceData` to clear that transient data when `table.reset()` runs. Reset hooks run after internally owned table state atoms have been restored to `table.initialState`. They do not reset table state slices or externally controlled state, and `table.reset()` does not rerun `initTableInstanceData`. diff --git a/docs/framework/solid/guide/custom-features.md b/docs/framework/solid/guide/custom-features.md index 6faf75b019..6350eee0ce 100644 --- a/docs/framework/solid/guide/custom-features.md +++ b/docs/framework/solid/guide/custom-features.md @@ -140,7 +140,7 @@ The `getInitialState` method in a table feature is responsible for setting the d #### initTableInstanceData and resetTableInstanceData -Use `initTableInstanceData` for mutable, non-reactive data that belongs to one table instance, such as an interaction anchor or an imperative cache. It runs once after table options, state atoms, and the store have been created. Every feature's initialization hook runs before any feature's `constructTableAPIs` hook. +Use `initTableInstanceData` for mutable, non-reactive data that belongs to one table instance, such as an interaction anchor or an imperative cache. It runs once after table options, state atoms, and the store have been created. Features are processed in a single pass in registration order; each feature's initialization hook runs just before that feature's `constructTableAPIs` hook, so hooks may rely on data and APIs from features registered earlier. Use `resetTableInstanceData` to clear that transient data when `table.reset()` runs. Reset hooks run after internally owned table state atoms have been restored to `table.initialState`. They do not reset table state slices or externally controlled state, and `table.reset()` does not rerun `initTableInstanceData`. diff --git a/docs/framework/svelte/guide/custom-features.md b/docs/framework/svelte/guide/custom-features.md index 81edc25879..509d91c658 100644 --- a/docs/framework/svelte/guide/custom-features.md +++ b/docs/framework/svelte/guide/custom-features.md @@ -140,7 +140,7 @@ The `getInitialState` method in a table feature is responsible for setting the d #### initTableInstanceData and resetTableInstanceData -Use `initTableInstanceData` for mutable, non-reactive data that belongs to one table instance, such as an interaction anchor or an imperative cache. It runs once after table options, state atoms, and the store have been created. Every feature's initialization hook runs before any feature's `constructTableAPIs` hook. +Use `initTableInstanceData` for mutable, non-reactive data that belongs to one table instance, such as an interaction anchor or an imperative cache. It runs once after table options, state atoms, and the store have been created. Features are processed in a single pass in registration order; each feature's initialization hook runs just before that feature's `constructTableAPIs` hook, so hooks may rely on data and APIs from features registered earlier. Use `resetTableInstanceData` to clear that transient data when `table.reset()` runs. Reset hooks run after internally owned table state atoms have been restored to `table.initialState`. They do not reset table state slices or externally controlled state, and `table.reset()` does not rerun `initTableInstanceData`. diff --git a/docs/framework/vue/guide/custom-features.md b/docs/framework/vue/guide/custom-features.md index 862de8122d..c51ed3d4a6 100644 --- a/docs/framework/vue/guide/custom-features.md +++ b/docs/framework/vue/guide/custom-features.md @@ -140,7 +140,7 @@ The `getInitialState` method in a table feature is responsible for setting the d #### initTableInstanceData and resetTableInstanceData -Use `initTableInstanceData` for mutable, non-reactive data that belongs to one table instance, such as an interaction anchor or an imperative cache. It runs once after table options, state atoms, and the store have been created. Every feature's initialization hook runs before any feature's `constructTableAPIs` hook. +Use `initTableInstanceData` for mutable, non-reactive data that belongs to one table instance, such as an interaction anchor or an imperative cache. It runs once after table options, state atoms, and the store have been created. Features are processed in a single pass in registration order; each feature's initialization hook runs just before that feature's `constructTableAPIs` hook, so hooks may rely on data and APIs from features registered earlier. Use `resetTableInstanceData` to clear that transient data when `table.reset()` runs. Reset hooks run after internally owned table state atoms have been restored to `table.initialState`. They do not reset table state slices or externally controlled state, and `table.reset()` does not rerun `initTableInstanceData`. diff --git a/examples/lit/basic-subscribe/src/main.ts b/examples/lit/basic-subscribe/src/main.ts index e2d5c1d2ab..554e42d515 100644 --- a/examples/lit/basic-subscribe/src/main.ts +++ b/examples/lit/basic-subscribe/src/main.ts @@ -118,18 +118,25 @@ class LitTableExample extends LitElement { private tableController = new TableController(this) - private table = this.tableController.table( - { + // Options are re-synced on every render pass (like the other examples), so + // reactive inputs such as `this._data` flow into the table in the same + // update that renders them. + private tableOptions() { + return { features, columns, data: this._data, - getRowId: (row) => row.id, + getRowId: (row: Person) => row.id, enableRowSelection: true, atoms: { rowSelection: rowSelectionAtom, }, debugTable: true, - }, + } + } + + private table = this.tableController.table( + this.tableOptions(), () => null, // subscribe to no table state by default; use table.subscribe below ) @@ -141,12 +148,6 @@ class LitTableExample extends LitElement { pagination: state.pagination, }) - protected updated(changedProperties: Map) { - if (changedProperties.has('_data')) { - this.table.setOptions((prev) => ({ ...prev, data: this._data })) - } - } - private renderColumnFilter( context: HeaderContext, ) { @@ -211,6 +212,8 @@ class LitTableExample extends LitElement { } protected render() { + this.table = this.tableController.table(this.tableOptions(), () => null) + return html`
diff --git a/examples/preact/basic-external-state/tests/e2e/smoke.spec.ts b/examples/preact/basic-external-state/tests/e2e/smoke.spec.ts index 6188138993..d0881ae9dc 100644 --- a/examples/preact/basic-external-state/tests/e2e/smoke.spec.ts +++ b/examples/preact/basic-external-state/tests/e2e/smoke.spec.ts @@ -102,3 +102,37 @@ test('renders the table without crashing', async ({ page }) => { await server.close() } }) + +test('updates controlled pagination without console errors', async ({ + page, +}) => { + const { errors, server } = await openExample(page) + + try { + const table = getTable(page) + const nextPageButton = page.getByRole('button', { + name: '>', + exact: true, + }) + const pageStatus = page.getByText(/^Page$/).locator('..') + + await expect(getBodyRows(table).first()).toBeVisible() + await expect(pageStatus).toContainText('1 of 100') + const firstPageRow = await getFirstBodyRowData(table) + + await nextPageButton.click() + + await expect(pageStatus).toContainText('2 of 100') + await expect.poll(() => getFirstBodyRowData(table)).not.toBe(firstPageRow) + + const secondPageRow = await getFirstBodyRowData(table) + + await nextPageButton.click() + + await expect(pageStatus).toContainText('3 of 100') + await expect.poll(() => getFirstBodyRowData(table)).not.toBe(secondPageRow) + expect(errors).toEqual([]) + } finally { + await server.close() + } +}) diff --git a/examples/react/aggregation/src/main.tsx b/examples/react/aggregation/src/main.tsx index 6e750934ea..955f4943fb 100644 --- a/examples/react/aggregation/src/main.tsx +++ b/examples/react/aggregation/src/main.tsx @@ -302,7 +302,9 @@ function App() { Showing {table.getRowModel().rows.length.toLocaleString()} of{' '} {table.getRowCount().toLocaleString()} Rows
-
{JSON.stringify(table.state, null, 2)}
+
+        {JSON.stringify(table.state, null, 2)}
+      
) } diff --git a/examples/react/basic-external-atoms/src/main.tsx b/examples/react/basic-external-atoms/src/main.tsx index 6a1dd8a7c8..ac4e5838e5 100644 --- a/examples/react/basic-external-atoms/src/main.tsx +++ b/examples/react/basic-external-atoms/src/main.tsx @@ -2,7 +2,7 @@ import React from 'react' import { TanStackDevtools } from '@tanstack/react-devtools' import ReactDOM from 'react-dom/client' import './index.css' -import { useCreateAtom, useSelector } from '@tanstack/react-store' +import { useCreateAtom } from '@tanstack/react-store' import { createColumnHelper, createPaginatedRowModel, @@ -79,14 +79,8 @@ function App() { pageSize: 10, }) - // Subscribe to each atom independently — fine-grained reactivity. - const sorting = useSelector(sortingAtom) - const pagination = useSelector(paginationAtom) - - console.log('sorting', sorting) - console.log('pagination', pagination) - - // Create the table and pass your per-slice external atoms. + // Pass the per-slice external atoms directly. The React adapter subscribes + // through table.store, so no separate useSelector call is needed here. const table = useTable( { key: 'basic-external-atoms', // needed for devtools @@ -156,7 +150,7 @@ function App() {
-
{JSON.stringify(table.state, null, 2)}
+
+        {JSON.stringify(table.state, null, 2)}
+      
) } diff --git a/examples/react/basic-external-atoms/tests/e2e/smoke.spec.ts b/examples/react/basic-external-atoms/tests/e2e/smoke.spec.ts index fc9a7906fb..4e8c89fde0 100644 --- a/examples/react/basic-external-atoms/tests/e2e/smoke.spec.ts +++ b/examples/react/basic-external-atoms/tests/e2e/smoke.spec.ts @@ -84,3 +84,30 @@ test('regenerates table data', async ({ page }) => { await server.close() } }) + +test('updates from external atoms without an outer React subscription', async ({ + page, +}) => { + const { errors, server } = await openExample(page) + + try { + const table = page.locator('table').first() + const nextPageButton = page.getByRole('button', { + name: '>', + exact: true, + }) + const pageStatus = page.getByText(/^Page$/).locator('..') + + await expect(table.locator('tbody tr').first()).toBeVisible() + await expect(pageStatus).toContainText('1 of 100') + const firstPageRow = await getFirstBodyRowText(table) + + await nextPageButton.click() + + await expect(pageStatus).toContainText('2 of 100') + await expect.poll(() => getFirstBodyRowText(table)).not.toBe(firstPageRow) + expect(errors).toEqual([]) + } finally { + await server.close() + } +}) diff --git a/examples/react/basic-external-state/src/main.tsx b/examples/react/basic-external-state/src/main.tsx index 94631fbbe2..5fc16f303d 100644 --- a/examples/react/basic-external-state/src/main.tsx +++ b/examples/react/basic-external-state/src/main.tsx @@ -154,7 +154,7 @@ function App() {
-
{JSON.stringify(table.state, null, 2)}
+
+        {JSON.stringify(table.state, null, 2)}
+      
) } diff --git a/examples/react/basic-external-state/tests/e2e/smoke.spec.ts b/examples/react/basic-external-state/tests/e2e/smoke.spec.ts index fc9a7906fb..2c12272e09 100644 --- a/examples/react/basic-external-state/tests/e2e/smoke.spec.ts +++ b/examples/react/basic-external-state/tests/e2e/smoke.spec.ts @@ -84,3 +84,40 @@ test('regenerates table data', async ({ page }) => { await server.close() } }) + +test('updates controlled pagination without render-phase errors', async ({ + page, +}) => { + const { errors, server } = await openExample(page) + + try { + const table = page.locator('table').first() + const nextPageButton = page.getByRole('button', { + name: '>', + exact: true, + }) + const pageStatus = page.getByText(/^Page$/).locator('..') + + await expect(table.locator('tbody tr').first()).toBeVisible() + await expect(nextPageButton).toBeEnabled() + await expect(pageStatus).toContainText('1 of 100') + + const firstPageRow = await getFirstBodyRowText(table) + + await nextPageButton.click() + + await expect(pageStatus).toContainText('2 of 100') + await expect.poll(() => getFirstBodyRowText(table)).not.toBe(firstPageRow) + expect(errors.join('\n')).not.toContain('Cannot update a component') + + const secondPageRow = await getFirstBodyRowText(table) + + await nextPageButton.click() + + await expect(pageStatus).toContainText('3 of 100') + await expect.poll(() => getFirstBodyRowText(table)).not.toBe(secondPageRow) + expect(errors.join('\n')).not.toContain('Cannot update a component') + } finally { + await server.close() + } +}) diff --git a/examples/react/basic-subscribe/src/main.tsx b/examples/react/basic-subscribe/src/main.tsx index e5901be486..79122c53c2 100644 --- a/examples/react/basic-subscribe/src/main.tsx +++ b/examples/react/basic-subscribe/src/main.tsx @@ -269,7 +269,7 @@ function App() {
) diff --git a/examples/react/column-ordering/src/main.tsx b/examples/react/column-ordering/src/main.tsx index 3fb1f8317e..022cc53c6a 100644 --- a/examples/react/column-ordering/src/main.tsx +++ b/examples/react/column-ordering/src/main.tsx @@ -183,7 +183,9 @@ function App() { ))} -
{JSON.stringify(table.state, null, 2)}
+
+        {JSON.stringify(table.state, null, 2)}
+      
) } diff --git a/examples/react/column-ordering/tests/e2e/smoke.spec.ts b/examples/react/column-ordering/tests/e2e/smoke.spec.ts index 2eb199bf28..9ccdc15f89 100644 --- a/examples/react/column-ordering/tests/e2e/smoke.spec.ts +++ b/examples/react/column-ordering/tests/e2e/smoke.spec.ts @@ -1,10 +1,32 @@ -import { expect, test } from '@playwright/test' -import type { Locator, Page } from '@playwright/test' import path from 'node:path' +import { expect, test } from '@playwright/test' import { startExampleServer } from '../../../../../tests/e2e/helpers/startExampleServer' +import type { Page } from '@playwright/test' const exampleDir = path.resolve() +/** Leaf columns in their declared order. The toggle panel labels each by id. */ +const LEAF_COLUMNS = [ + 'firstName', + 'lastName', + 'age', + 'visits', + 'status', + 'progress', +] + +let server: Awaited> | undefined + +test.beforeAll(async () => { + // One server for the whole file. A cold Vite start costs more than a test. + test.setTimeout(180_000) + server = await startExampleServer(exampleDir) +}) + +test.afterAll(async () => { + await server?.close() +}) + function collectPageErrors(page: Page) { const errors: Array = [] @@ -22,7 +44,8 @@ function collectPageErrors(page: Page) { } async function openExample(page: Page) { - const server = await startExampleServer(exampleDir) + if (!server) throw new Error('Example server failed to start') + const errors = collectPageErrors(page) await page.route( @@ -36,51 +59,159 @@ async function openExample(page: Page) { await page.goto(server.url) - return { errors, server } + return errors } -async function getFirstBodyRowText(table: Locator) { - const text = await table.locator('tbody tr').first().textContent() - return text?.replace(/\s+/g, ' ').trim() ?? '' +function shuffleButton(page: Page) { + return page.getByRole('button', { name: 'Shuffle Columns' }) } -test('renders the table without crashing', async ({ page }) => { - const { errors, server } = await openExample(page) +function columnToggle(page: Page, label: string) { + return page.getByLabel(label, { exact: true }) +} + +/** Header rows run group to leaf, so the leaf row holds the visible columns. */ +function leafHeaderCells(page: Page) { + return page.locator('thead tr').last().locator('th') +} - try { - const table = page.locator('table').first() +async function readHeaderOrder(page: Page) { + const texts = await leafHeaderCells(page).allTextContents() + return texts.map((text) => text.trim()) +} + +/** Row data is random faker output, so `table.state` is the stable oracle. */ +async function readState(page: Page) { + const text = await page.getByTestId('table-state').textContent() - await expect(table).toBeVisible() - await expect(table.locator('thead th').first()).toBeVisible() - await expect(table.locator('tbody tr').first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() + return JSON.parse(text ?? '{}') as { + columnOrder?: Array + columnVisibility?: Record } +} + +async function getFirstBodyRowText(page: Page) { + const text = await page.locator('tbody tr').first().textContent() + return text?.replace(/\s+/g, ' ').trim() ?? '' +} + +test('renders the table without crashing', async ({ page }) => { + const errors = await openExample(page) + const table = page.locator('table').first() + + await expect(table).toBeVisible() + await expect(leafHeaderCells(page)).toHaveCount(LEAF_COLUMNS.length) + await expect(page.locator('tbody tr')).toHaveCount(20) + await expect(shuffleButton(page)).toBeVisible() + // No explicit order yet, so the columns render as declared. + await expect + .poll(async () => (await readState(page)).columnOrder ?? []) + .toEqual([]) + + expect(errors).toEqual([]) }) test('regenerates table data', async ({ page }) => { - const { errors, server } = await openExample(page) + const errors = await openExample(page) + const regenerateButton = page.getByRole('button', { + name: /^Regenerate Data$/i, + }) - try { - const table = page.locator('table').first() - const bodyRows = table.locator('tbody tr') - const regenerateButton = page.getByRole('button', { - name: /^Regenerate Data$/i, - }) + await expect(page.locator('tbody tr').first()).toBeVisible() - await expect(table).toBeVisible() - await expect(bodyRows.first()).toBeVisible() - await expect(regenerateButton).toBeVisible() + const firstRowBefore = await getFirstBodyRowText(page) - const firstRowBefore = await getFirstBodyRowText(table) + await regenerateButton.click() - await regenerateButton.click() + await expect.poll(() => getFirstBodyRowText(page)).not.toBe(firstRowBefore) + await expect(page.locator('tbody tr')).toHaveCount(20) - await expect.poll(() => getFirstBodyRowText(table)).not.toBe(firstRowBefore) - await expect(bodyRows.first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() - } + expect(errors).toEqual([]) +}) + +test('shuffles the columns into a new order', async ({ page }) => { + const errors = await openExample(page) + const before = await readHeaderOrder(page) + + await shuffleButton(page).click() + + await expect + .poll(async () => (await readState(page)).columnOrder ?? []) + .toHaveLength(LEAF_COLUMNS.length) + + const after = await readHeaderOrder(page) + + // Shuffling permutes the columns: same set, different sequence. + expect([...after].sort()).toEqual([...before].sort()) + expect(after).not.toEqual(before) + + const order = (await readState(page)).columnOrder ?? [] + expect([...order].sort()).toEqual([...LEAF_COLUMNS].sort()) + + expect(errors).toEqual([]) +}) + +test('reorders the body cells to match the headers', async ({ page }) => { + const errors = await openExample(page) + + await shuffleButton(page).click() + await expect + .poll(async () => (await readState(page)).columnOrder ?? []) + .toHaveLength(LEAF_COLUMNS.length) + + const order = (await readState(page)).columnOrder ?? [] + const footerOrder = ( + await page.locator('tfoot tr').first().locator('th').allTextContents() + ).map((text) => text.trim()) + + // The footer renders raw column ids, so it must match the shuffled order + // exactly. That proves the cells moved with their headers. + expect(footerOrder).toEqual(order) + + expect(errors).toEqual([]) +}) + +test('keeps the shuffled order when a column is hidden', async ({ page }) => { + const errors = await openExample(page) + + await shuffleButton(page).click() + await expect + .poll(async () => (await readState(page)).columnOrder ?? []) + .toHaveLength(LEAF_COLUMNS.length) + + const order = (await readState(page)).columnOrder ?? [] + + await columnToggle(page, 'age').uncheck() + + await expect + .poll(async () => (await readState(page)).columnVisibility ?? {}) + .toEqual({ age: false }) + await expect(leafHeaderCells(page)).toHaveCount(LEAF_COLUMNS.length - 1) + + // Hiding removes a column from the render without disturbing the rest. + const footerOrder = ( + await page.locator('tfoot tr').first().locator('th').allTextContents() + ).map((text) => text.trim()) + expect(footerOrder).toEqual(order.filter((id) => id !== 'age')) + + expect(errors).toEqual([]) +}) + +test('keeps the shuffled order when data is regenerated', async ({ page }) => { + const errors = await openExample(page) + + await shuffleButton(page).click() + await expect + .poll(async () => (await readState(page)).columnOrder ?? []) + .toHaveLength(LEAF_COLUMNS.length) + + const order = await readHeaderOrder(page) + + // Column order lives in table state, so replacing the rows must not reset it. + await page.getByRole('button', { name: /^Regenerate Data$/i }).click() + + await expect(page.locator('tbody tr')).toHaveCount(20) + expect(await readHeaderOrder(page)).toEqual(order) + + expect(errors).toEqual([]) }) diff --git a/examples/react/column-pinning-split/src/main.tsx b/examples/react/column-pinning-split/src/main.tsx index 43e10dfbd5..747de17796 100644 --- a/examples/react/column-pinning-split/src/main.tsx +++ b/examples/react/column-pinning-split/src/main.tsx @@ -359,7 +359,9 @@ function App() { -
{JSON.stringify(table.state, null, 2)}
+
+        {JSON.stringify(table.state, null, 2)}
+      
) } diff --git a/examples/react/column-pinning-split/tests/e2e/smoke.spec.ts b/examples/react/column-pinning-split/tests/e2e/smoke.spec.ts index 404c500e1d..c101c0ca57 100644 --- a/examples/react/column-pinning-split/tests/e2e/smoke.spec.ts +++ b/examples/react/column-pinning-split/tests/e2e/smoke.spec.ts @@ -1,10 +1,29 @@ -import { expect, test } from '@playwright/test' -import type { Locator, Page } from '@playwright/test' import path from 'node:path' +import { expect, test } from '@playwright/test' import { startExampleServer } from '../../../../../tests/e2e/helpers/startExampleServer' +import type { Page } from '@playwright/test' const exampleDir = path.resolve() +const LEAF_COUNT = 6 + +/** The three tables render the start pinned, unpinned and end pinned columns. */ +const START_TABLE = 0 +const CENTER_TABLE = 1 +const END_TABLE = 2 + +let server: Awaited> | undefined + +test.beforeAll(async () => { + // One server for the whole file. A cold Vite start costs more than a test. + test.setTimeout(180_000) + server = await startExampleServer(exampleDir) +}) + +test.afterAll(async () => { + await server?.close() +}) + function collectPageErrors(page: Page) { const errors: Array = [] @@ -22,7 +41,8 @@ function collectPageErrors(page: Page) { } async function openExample(page: Page) { - const server = await startExampleServer(exampleDir) + if (!server) throw new Error('Example server failed to start') + const errors = collectPageErrors(page) await page.route( @@ -36,51 +56,164 @@ async function openExample(page: Page) { await page.goto(server.url) - return { errors, server } + return errors +} + +function tableAt(page: Page, index: number) { + return page.locator('table.outlined-table').nth(index) +} + +/** Header rows run group to leaf, so the leaf row holds the data columns. */ +function leafHeaderCells(page: Page, tableIndex: number) { + return tableAt(page, tableIndex).locator('thead tr').last().locator('th') +} + +function leafHeader(page: Page, tableIndex: number, name: string) { + return leafHeaderCells(page, tableIndex).filter({ hasText: name }) +} + +/** An unpinned header offers `<=` and `=>`; a pinned one swaps in `X`. */ +function pinButton( + page: Page, + tableIndex: number, + name: string, + label: '<=' | 'X' | '=>', +) { + return leafHeader(page, tableIndex, name).getByRole('button', { + name: label, + exact: true, + }) +} + +/** Row data is random faker output, so `table.state` is the stable oracle. */ +async function readColumnPinning(page: Page) { + const text = await page.getByTestId('table-state').textContent() + const state = JSON.parse(text ?? '{}') as { + columnPinning?: { start?: Array; end?: Array } + } + + return { + start: state.columnPinning?.start ?? [], + end: state.columnPinning?.end ?? [], + } +} + +async function leafCount(page: Page, tableIndex: number) { + return leafHeaderCells(page, tableIndex).count() } -async function getFirstBodyRowText(table: Locator) { - const text = await table.locator('tbody tr').first().textContent() +async function getFirstBodyRowText(page: Page) { + const text = await tableAt(page, CENTER_TABLE) + .locator('tbody tr') + .first() + .textContent() return text?.replace(/\s+/g, ' ').trim() ?? '' } test('renders the table without crashing', async ({ page }) => { - const { errors, server } = await openExample(page) + const errors = await openExample(page) - try { - const table = page.locator('table').nth(1) + // One table per pinning region, even when two of them are empty. + await expect(page.locator('table.outlined-table')).toHaveCount(3) + expect(await readColumnPinning(page)).toEqual({ start: [], end: [] }) + // Nothing is pinned, so every column lives in the centre table. + expect(await leafCount(page, START_TABLE)).toBe(0) + expect(await leafCount(page, CENTER_TABLE)).toBe(LEAF_COUNT) + expect(await leafCount(page, END_TABLE)).toBe(0) - await expect(table).toBeVisible() - await expect(table.locator('thead th').first()).toBeVisible() - await expect(table.locator('tbody tr').first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() - } + expect(errors).toEqual([]) }) test('regenerates table data', async ({ page }) => { - const { errors, server } = await openExample(page) + const errors = await openExample(page) + const regenerateButton = page.getByRole('button', { + name: /^Regenerate Data$/i, + }) - try { - const table = page.locator('table').nth(1) - const bodyRows = table.locator('tbody tr') - const regenerateButton = page.getByRole('button', { - name: /^Regenerate Data$/i, - }) + await expect( + tableAt(page, CENTER_TABLE).locator('tbody tr').first(), + ).toBeVisible() - await expect(table).toBeVisible() - await expect(bodyRows.first()).toBeVisible() - await expect(regenerateButton).toBeVisible() + const firstRowBefore = await getFirstBodyRowText(page) - const firstRowBefore = await getFirstBodyRowText(table) + await regenerateButton.click() - await regenerateButton.click() + await expect.poll(() => getFirstBodyRowText(page)).not.toBe(firstRowBefore) - await expect.poll(() => getFirstBodyRowText(table)).not.toBe(firstRowBefore) - await expect(bodyRows.first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() - } + expect(errors).toEqual([]) +}) + +test('moves a column into the start table when pinned', async ({ page }) => { + const errors = await openExample(page) + + await pinButton(page, CENTER_TABLE, 'Visits', '<=').click() + + await expect + .poll(async () => (await readColumnPinning(page)).start) + .toEqual(['visits']) + // The column leaves the centre table and appears in the start table, so the + // split is driven by pinning state rather than by a separate column list. + await expect.poll(() => leafCount(page, START_TABLE)).toBe(1) + expect(await leafCount(page, CENTER_TABLE)).toBe(LEAF_COUNT - 1) + await expect(leafHeaderCells(page, START_TABLE)).toContainText(['Visits']) + await expect(leafHeaderCells(page, CENTER_TABLE)).not.toContainText([ + 'Visits', + ]) + + expect(errors).toEqual([]) +}) + +test('moves a column into the end table when pinned', async ({ page }) => { + const errors = await openExample(page) + + await pinButton(page, CENTER_TABLE, 'Age', '=>').click() + + await expect + .poll(async () => (await readColumnPinning(page)).end) + .toEqual(['age']) + await expect.poll(() => leafCount(page, END_TABLE)).toBe(1) + expect(await leafCount(page, CENTER_TABLE)).toBe(LEAF_COUNT - 1) + await expect(leafHeaderCells(page, END_TABLE)).toContainText(['Age']) + + expect(errors).toEqual([]) +}) + +test('returns a column to the centre table when unpinned', async ({ page }) => { + const errors = await openExample(page) + + await pinButton(page, CENTER_TABLE, 'Visits', '<=').click() + await expect.poll(() => leafCount(page, START_TABLE)).toBe(1) + + await pinButton(page, START_TABLE, 'Visits', 'X').click() + + await expect + .poll(() => readColumnPinning(page)) + .toEqual({ start: [], end: [] }) + await expect.poll(() => leafCount(page, START_TABLE)).toBe(0) + expect(await leafCount(page, CENTER_TABLE)).toBe(LEAF_COUNT) + + expect(errors).toEqual([]) +}) + +test('keeps every table showing the same rows', async ({ page }) => { + const errors = await openExample(page) + + await pinButton(page, CENTER_TABLE, 'Visits', '<=').click() + await expect.poll(() => leafCount(page, START_TABLE)).toBe(1) + await pinButton(page, CENTER_TABLE, 'Age', '=>').click() + await expect.poll(() => leafCount(page, END_TABLE)).toBe(1) + + // The three tables are one table split three ways, so their bodies must stay + // row for row aligned or the split would visibly tear. + const rowCounts = await Promise.all( + [START_TABLE, CENTER_TABLE, END_TABLE].map((index) => + tableAt(page, index).locator('tbody tr').count(), + ), + ) + + expect(rowCounts[0]).toBeGreaterThan(0) + expect(rowCounts[1]).toBe(rowCounts[0]) + expect(rowCounts[2]).toBe(rowCounts[0]) + + expect(errors).toEqual([]) }) diff --git a/examples/react/column-pinning-sticky/src/main.tsx b/examples/react/column-pinning-sticky/src/main.tsx index 6f00a9eff0..f50d82a4d8 100644 --- a/examples/react/column-pinning-sticky/src/main.tsx +++ b/examples/react/column-pinning-sticky/src/main.tsx @@ -273,7 +273,9 @@ function App() { -
{JSON.stringify(table.state, null, 2)}
+
+        {JSON.stringify(table.state, null, 2)}
+      
) } diff --git a/examples/react/column-pinning-sticky/tests/e2e/smoke.spec.ts b/examples/react/column-pinning-sticky/tests/e2e/smoke.spec.ts index 2eb199bf28..e8aa894e8d 100644 --- a/examples/react/column-pinning-sticky/tests/e2e/smoke.spec.ts +++ b/examples/react/column-pinning-sticky/tests/e2e/smoke.spec.ts @@ -1,10 +1,22 @@ -import { expect, test } from '@playwright/test' -import type { Locator, Page } from '@playwright/test' import path from 'node:path' +import { expect, test } from '@playwright/test' import { startExampleServer } from '../../../../../tests/e2e/helpers/startExampleServer' +import type { Page } from '@playwright/test' const exampleDir = path.resolve() +let server: Awaited> | undefined + +test.beforeAll(async () => { + // One server for the whole file. A cold Vite start costs more than a test. + test.setTimeout(180_000) + server = await startExampleServer(exampleDir) +}) + +test.afterAll(async () => { + await server?.close() +}) + function collectPageErrors(page: Page) { const errors: Array = [] @@ -22,7 +34,8 @@ function collectPageErrors(page: Page) { } async function openExample(page: Page) { - const server = await startExampleServer(exampleDir) + if (!server) throw new Error('Example server failed to start') + const errors = collectPageErrors(page) await page.route( @@ -36,51 +49,159 @@ async function openExample(page: Page) { await page.goto(server.url) - return { errors, server } + return errors +} + +/** Header rows run group to leaf, so the leaf row holds the data columns. */ +function leafHeaderCells(page: Page) { + return page.locator('thead tr').last().locator('th') +} + +function leafHeader(page: Page, name: string) { + return leafHeaderCells(page).filter({ hasText: name }) +} + +/** An unpinned header offers `<=` and `=>`; a pinned one swaps in `X`. */ +function pinButton(page: Page, name: string, label: '<=' | 'X' | '=>') { + return leafHeader(page, name).getByRole('button', { + name: label, + exact: true, + }) +} + +function scrollContainer(page: Page) { + return page.locator('.table-container') +} + +/** Row data is random faker output, so `table.state` is the stable oracle. */ +async function readColumnPinning(page: Page) { + const text = await page.getByTestId('table-state').textContent() + const state = JSON.parse(text ?? '{}') as { + columnPinning?: { start?: Array; end?: Array } + } + + return { + start: state.columnPinning?.start ?? [], + end: state.columnPinning?.end ?? [], + } } -async function getFirstBodyRowText(table: Locator) { - const text = await table.locator('tbody tr').first().textContent() +/** + * Scrolls the container as far right as it goes and returns the distance + * travelled. How much room there is depends on the viewport, so the tests work + * from the real value rather than assuming one. + */ +async function scrollToEnd(page: Page) { + const maxScroll = await scrollContainer(page).evaluate( + (element) => element.scrollWidth - element.clientWidth, + ) + + expect(maxScroll).toBeGreaterThan(50) + + await scrollContainer(page).evaluate((element) => { + element.scrollLeft = element.scrollWidth + element.dispatchEvent(new Event('scroll')) + }) + + await expect + .poll(() => scrollContainer(page).evaluate((element) => element.scrollLeft)) + .toBeGreaterThan(maxScroll - 2) + + return maxScroll +} + +async function getFirstBodyRowText(page: Page) { + const text = await page.locator('tbody tr').first().textContent() return text?.replace(/\s+/g, ' ').trim() ?? '' } test('renders the table without crashing', async ({ page }) => { - const { errors, server } = await openExample(page) + const errors = await openExample(page) - try { - const table = page.locator('table').first() + await expect(page.locator('table').first()).toBeVisible() + await expect(page.locator('tbody tr')).toHaveCount(20) + await expect(scrollContainer(page)).toBeVisible() + expect(await readColumnPinning(page)).toEqual({ start: [], end: [] }) + // Unpinned columns scroll with the rest, so they are not sticky. + await expect(leafHeaderCells(page).first()).toHaveCSS('position', 'relative') - await expect(table).toBeVisible() - await expect(table.locator('thead th').first()).toBeVisible() - await expect(table.locator('tbody tr').first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() - } + expect(errors).toEqual([]) }) test('regenerates table data', async ({ page }) => { - const { errors, server } = await openExample(page) + const errors = await openExample(page) + const regenerateButton = page.getByRole('button', { + name: /^Regenerate Data$/i, + }) - try { - const table = page.locator('table').first() - const bodyRows = table.locator('tbody tr') - const regenerateButton = page.getByRole('button', { - name: /^Regenerate Data$/i, - }) + await expect(page.locator('tbody tr').first()).toBeVisible() - await expect(table).toBeVisible() - await expect(bodyRows.first()).toBeVisible() - await expect(regenerateButton).toBeVisible() + const firstRowBefore = await getFirstBodyRowText(page) - const firstRowBefore = await getFirstBodyRowText(table) + await regenerateButton.click() - await regenerateButton.click() + await expect.poll(() => getFirstBodyRowText(page)).not.toBe(firstRowBefore) + await expect(page.locator('tbody tr')).toHaveCount(20) - await expect.poll(() => getFirstBodyRowText(table)).not.toBe(firstRowBefore) - await expect(bodyRows.first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() - } + expect(errors).toEqual([]) +}) + +test('makes a pinned column sticky', async ({ page }) => { + const errors = await openExample(page) + + await pinButton(page, 'Visits', '<=').click() + + await expect + .poll(async () => (await readColumnPinning(page)).start) + .toEqual(['visits']) + // Pinning switches the cell from relative to sticky and gives it an offset, + // which is what actually holds it in place while the rest scrolls. + await expect(leafHeader(page, 'Visits')).toHaveCSS('position', 'sticky') + await expect(leafHeader(page, 'Visits')).toHaveCSS('left', '0px') + + expect(errors).toEqual([]) +}) + +test('holds a start pinned column in place while scrolling', async ({ + page, +}) => { + const errors = await openExample(page) + + await pinButton(page, 'Visits', '<=').click() + await expect(leafHeader(page, 'Visits')).toHaveCSS('position', 'sticky') + + const pinnedBefore = await leafHeader(page, 'Visits').boundingBox() + const unpinnedBefore = await leafHeader(page, 'Status').boundingBox() + + const scrolled = await scrollToEnd(page) + + const pinnedAfter = await leafHeader(page, 'Visits').boundingBox() + const unpinnedAfter = await leafHeader(page, 'Status').boundingBox() + + // The pinned header stays put while its unpinned neighbour travels left by + // the full scroll distance. That difference is the whole point of sticky. + expect(Math.abs((pinnedAfter?.x ?? 0) - (pinnedBefore?.x ?? 0))).toBeLessThan( + 2, + ) + expect((unpinnedBefore?.x ?? 0) - (unpinnedAfter?.x ?? 0)).toBeGreaterThan( + scrolled - 2, + ) + + expect(errors).toEqual([]) +}) + +test('unpins a column back into the scrolling area', async ({ page }) => { + const errors = await openExample(page) + + await pinButton(page, 'Visits', '<=').click() + await expect(leafHeader(page, 'Visits')).toHaveCSS('position', 'sticky') + + await pinButton(page, 'Visits', 'X').click() + + await expect + .poll(() => readColumnPinning(page)) + .toEqual({ start: [], end: [] }) + await expect(leafHeader(page, 'Visits')).toHaveCSS('position', 'relative') + + expect(errors).toEqual([]) }) diff --git a/examples/react/column-pinning/src/main.tsx b/examples/react/column-pinning/src/main.tsx index f0786e79d6..d65a8a0fdf 100644 --- a/examples/react/column-pinning/src/main.tsx +++ b/examples/react/column-pinning/src/main.tsx @@ -224,7 +224,9 @@ function App() { -
{JSON.stringify(table.state, null, 2)}
+
+        {JSON.stringify(table.state, null, 2)}
+      
) } diff --git a/examples/react/column-pinning/tests/e2e/smoke.spec.ts b/examples/react/column-pinning/tests/e2e/smoke.spec.ts index 2eb199bf28..9e087d6d16 100644 --- a/examples/react/column-pinning/tests/e2e/smoke.spec.ts +++ b/examples/react/column-pinning/tests/e2e/smoke.spec.ts @@ -1,10 +1,31 @@ -import { expect, test } from '@playwright/test' -import type { Locator, Page } from '@playwright/test' import path from 'node:path' +import { expect, test } from '@playwright/test' import { startExampleServer } from '../../../../../tests/e2e/helpers/startExampleServer' +import type { Page } from '@playwright/test' const exampleDir = path.resolve() +const LEAF_HEADERS = [ + 'firstName', + 'Last Name', + 'Age', + 'Visits', + 'Status', + 'Profile Progress', +] + +let server: Awaited> | undefined + +test.beforeAll(async () => { + // One server for the whole file. A cold Vite start costs more than a test. + test.setTimeout(180_000) + server = await startExampleServer(exampleDir) +}) + +test.afterAll(async () => { + await server?.close() +}) + function collectPageErrors(page: Page) { const errors: Array = [] @@ -22,7 +43,8 @@ function collectPageErrors(page: Page) { } async function openExample(page: Page) { - const server = await startExampleServer(exampleDir) + if (!server) throw new Error('Example server failed to start') + const errors = collectPageErrors(page) await page.route( @@ -36,51 +58,180 @@ async function openExample(page: Page) { await page.goto(server.url) - return { errors, server } + return errors } -async function getFirstBodyRowText(table: Locator) { - const text = await table.locator('tbody tr').first().textContent() - return text?.replace(/\s+/g, ' ').trim() ?? '' +/** Header rows run group to leaf, so the leaf row holds the data columns. */ +function leafHeaderCells(page: Page) { + return page.locator('thead tr').last().locator('th') } -test('renders the table without crashing', async ({ page }) => { - const { errors, server } = await openExample(page) +function leafHeader(page: Page, name: string) { + return leafHeaderCells(page).filter({ hasText: name }) +} - try { - const table = page.locator('table').first() +/** An unpinned header offers `<=` and `=>`; a pinned one swaps in `X`. */ +function pinButton(page: Page, name: string, label: '<=' | 'X' | '=>') { + return leafHeader(page, name).getByRole('button', { + name: label, + exact: true, + }) +} + +/** Row data is random faker output, so `table.state` is the stable oracle. */ +async function readColumnPinning(page: Page) { + const text = await page.getByTestId('table-state').textContent() + const state = JSON.parse(text ?? '{}') as { + columnPinning?: { start?: Array; end?: Array } + } - await expect(table).toBeVisible() - await expect(table.locator('thead th').first()).toBeVisible() - await expect(table.locator('tbody tr').first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() + return { + start: state.columnPinning?.start ?? [], + end: state.columnPinning?.end ?? [], } +} + +async function readLeafOrder(page: Page) { + const texts = await leafHeaderCells(page).allTextContents() + // Strip the pin controls that share the header cell with its label. + return texts.map((text) => text.replace(/<=|=>|X/g, '').trim()) +} + +async function getFirstBodyRowText(page: Page) { + const text = await page.locator('tbody tr').first().textContent() + return text?.replace(/\s+/g, ' ').trim() ?? '' +} + +test('renders the table without crashing', async ({ page }) => { + const errors = await openExample(page) + const table = page.locator('table').first() + + await expect(table).toBeVisible() + await expect(leafHeaderCells(page)).toHaveCount(LEAF_HEADERS.length) + expect(await readLeafOrder(page)).toEqual(LEAF_HEADERS) + expect(await readColumnPinning(page)).toEqual({ start: [], end: [] }) + // Nothing is pinned, so every header offers both directions and no unpin. + await expect(pinButton(page, 'Visits', '<=')).toBeVisible() + await expect(pinButton(page, 'Visits', '=>')).toBeVisible() + await expect(pinButton(page, 'Visits', 'X')).toHaveCount(0) + + expect(errors).toEqual([]) }) test('regenerates table data', async ({ page }) => { - const { errors, server } = await openExample(page) + const errors = await openExample(page) + const regenerateButton = page.getByRole('button', { + name: /^Regenerate Data$/i, + }) - try { - const table = page.locator('table').first() - const bodyRows = table.locator('tbody tr') - const regenerateButton = page.getByRole('button', { - name: /^Regenerate Data$/i, - }) + await expect(page.locator('tbody tr').first()).toBeVisible() - await expect(table).toBeVisible() - await expect(bodyRows.first()).toBeVisible() - await expect(regenerateButton).toBeVisible() + const firstRowBefore = await getFirstBodyRowText(page) - const firstRowBefore = await getFirstBodyRowText(table) + await regenerateButton.click() - await regenerateButton.click() + await expect.poll(() => getFirstBodyRowText(page)).not.toBe(firstRowBefore) - await expect.poll(() => getFirstBodyRowText(table)).not.toBe(firstRowBefore) - await expect(bodyRows.first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() - } + expect(errors).toEqual([]) +}) + +test('pins a column to the start', async ({ page }) => { + const errors = await openExample(page) + + await pinButton(page, 'Visits', '<=').click() + + await expect + .poll(async () => (await readColumnPinning(page)).start) + .toEqual(['visits']) + // The pinned column jumps to the front, keeping the others in order. + expect(await readLeafOrder(page)).toEqual([ + 'Visits', + 'firstName', + 'Last Name', + 'Age', + 'Status', + 'Profile Progress', + ]) + // A pinned header drops the direction it already occupies and gains unpin. + await expect(pinButton(page, 'Visits', '<=')).toHaveCount(0) + await expect(pinButton(page, 'Visits', 'X')).toBeVisible() + await expect(pinButton(page, 'Visits', '=>')).toBeVisible() + + expect(errors).toEqual([]) +}) + +test('pins a column to the end', async ({ page }) => { + const errors = await openExample(page) + + await pinButton(page, 'Age', '=>').click() + + await expect + .poll(async () => (await readColumnPinning(page)).end) + .toEqual(['age']) + expect(await readLeafOrder(page)).toEqual([ + 'firstName', + 'Last Name', + 'Visits', + 'Status', + 'Profile Progress', + 'Age', + ]) + await expect(pinButton(page, 'Age', '=>')).toHaveCount(0) + await expect(pinButton(page, 'Age', 'X')).toBeVisible() + + expect(errors).toEqual([]) +}) + +test('unpins a column', async ({ page }) => { + const errors = await openExample(page) + + await pinButton(page, 'Visits', '<=').click() + await expect + .poll(async () => (await readColumnPinning(page)).start) + .toEqual(['visits']) + + await pinButton(page, 'Visits', 'X').click() + + await expect + .poll(() => readColumnPinning(page)) + .toEqual({ start: [], end: [] }) + // Unpinning restores the column to its declared position. + expect(await readLeafOrder(page)).toEqual(LEAF_HEADERS) + await expect(pinButton(page, 'Visits', 'X')).toHaveCount(0) + + expect(errors).toEqual([]) +}) + +test('pins several columns in the order they were clicked', async ({ + page, +}) => { + const errors = await openExample(page) + + await pinButton(page, 'Visits', '<=').click() + await expect + .poll(async () => (await readColumnPinning(page)).start) + .toEqual(['visits']) + + await pinButton(page, 'Status', '<=').click() + + await expect + .poll(async () => (await readColumnPinning(page)).start) + .toEqual(['visits', 'status']) + + await pinButton(page, 'Age', '=>').click() + + await expect + .poll(async () => (await readColumnPinning(page)).end) + .toEqual(['age']) + // Start pins lead, unpinned columns hold the middle, end pins trail. + expect(await readLeafOrder(page)).toEqual([ + 'Visits', + 'Status', + 'firstName', + 'Last Name', + 'Profile Progress', + 'Age', + ]) + + expect(errors).toEqual([]) }) diff --git a/examples/react/column-resizing/src/main.tsx b/examples/react/column-resizing/src/main.tsx index c798ac9c3e..782ce37a6f 100644 --- a/examples/react/column-resizing/src/main.tsx +++ b/examples/react/column-resizing/src/main.tsx @@ -335,7 +335,9 @@ function App() {
-
{JSON.stringify(table.state, null, 2)}
+
+        {JSON.stringify(table.state, null, 2)}
+      
) } diff --git a/examples/react/column-sizing/src/main.tsx b/examples/react/column-sizing/src/main.tsx index a13c897f3e..b951a9397c 100644 --- a/examples/react/column-sizing/src/main.tsx +++ b/examples/react/column-sizing/src/main.tsx @@ -270,7 +270,9 @@ function App() {
-
{JSON.stringify(table.state, null, 2)}
+
+        {JSON.stringify(table.state, null, 2)}
+      
) } diff --git a/examples/react/column-sizing/tests/e2e/smoke.spec.ts b/examples/react/column-sizing/tests/e2e/smoke.spec.ts index 2eb199bf28..28f978689c 100644 --- a/examples/react/column-sizing/tests/e2e/smoke.spec.ts +++ b/examples/react/column-sizing/tests/e2e/smoke.spec.ts @@ -1,10 +1,34 @@ -import { expect, test } from '@playwright/test' -import type { Locator, Page } from '@playwright/test' import path from 'node:path' +import { expect, test } from '@playwright/test' import { startExampleServer } from '../../../../../tests/e2e/helpers/startExampleServer' +import type { Page } from '@playwright/test' const exampleDir = path.resolve() +/** Declared `size` per column, in render order. */ +const INITIAL_SIZES: Record = { + firstName: 120, + lastName: 120, + age: 100, + visits: 80, + status: 200, + progress: 200, +} + +const LEAF_COLUMNS = Object.keys(INITIAL_SIZES) + +let server: Awaited> | undefined + +test.beforeAll(async () => { + // One server for the whole file. A cold Vite start costs more than a test. + test.setTimeout(180_000) + server = await startExampleServer(exampleDir) +}) + +test.afterAll(async () => { + await server?.close() +}) + function collectPageErrors(page: Page) { const errors: Array = [] @@ -22,7 +46,8 @@ function collectPageErrors(page: Page) { } async function openExample(page: Page) { - const server = await startExampleServer(exampleDir) + if (!server) throw new Error('Example server failed to start') + const errors = collectPageErrors(page) await page.route( @@ -36,51 +61,140 @@ async function openExample(page: Page) { await page.goto(server.url) - return { errors, server } + return errors +} + +/** One number input per column in the Initial Column Sizes panel. */ +function sizeInput(page: Page, column: string) { + return page + .locator('label') + .filter({ hasText: new RegExp(`^${column}$`) }) + .locator('input.column-size-input') +} + +/** The example renders the same table three ways to prove they stay in sync. */ +function nativeHeader(page: Page, index: number) { + return page.locator('table thead th').nth(index) +} + +function relativeDivHeader(page: Page, index: number) { + return page.locator('.divTable').first().locator('.th').nth(index) +} + +function absoluteDivHeader(page: Page, index: number) { + return page.locator('.divTable').nth(1).locator('.th').nth(index) +} + +/** Row data is random faker output, so `table.state` is the stable oracle. */ +async function readColumnSizing(page: Page) { + const text = await page.getByTestId('table-state').textContent() + const state = JSON.parse(text ?? '{}') as { + columnSizing?: Record + } + + return state.columnSizing ?? {} +} + +async function widthOf(page: Page, index: number) { + const box = await nativeHeader(page, index).boundingBox() + return box?.width ?? 0 } -async function getFirstBodyRowText(table: Locator) { - const text = await table.locator('tbody tr').first().textContent() +async function getFirstBodyRowText(page: Page) { + const text = await page.locator('tbody tr').first().textContent() return text?.replace(/\s+/g, ' ').trim() ?? '' } test('renders the table without crashing', async ({ page }) => { - const { errors, server } = await openExample(page) + const errors = await openExample(page) - try { - const table = page.locator('table').first() + await expect(page.locator('table').first()).toBeVisible() + await expect(page.locator('tbody tr')).toHaveCount(20) + // No overrides applied yet, so sizes come straight from the column defs. + expect(await readColumnSizing(page)).toEqual({}) - await expect(table).toBeVisible() - await expect(table.locator('thead th').first()).toBeVisible() - await expect(table.locator('tbody tr').first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() - } + expect(errors).toEqual([]) }) test('regenerates table data', async ({ page }) => { - const { errors, server } = await openExample(page) + const errors = await openExample(page) + const regenerateButton = page.getByRole('button', { + name: /^Regenerate Data$/i, + }) - try { - const table = page.locator('table').first() - const bodyRows = table.locator('tbody tr') - const regenerateButton = page.getByRole('button', { - name: /^Regenerate Data$/i, - }) + await expect(page.locator('tbody tr').first()).toBeVisible() - await expect(table).toBeVisible() - await expect(bodyRows.first()).toBeVisible() - await expect(regenerateButton).toBeVisible() + const firstRowBefore = await getFirstBodyRowText(page) - const firstRowBefore = await getFirstBodyRowText(table) + await regenerateButton.click() - await regenerateButton.click() + await expect.poll(() => getFirstBodyRowText(page)).not.toBe(firstRowBefore) + await expect(page.locator('tbody tr')).toHaveCount(20) + + expect(errors).toEqual([]) +}) - await expect.poll(() => getFirstBodyRowText(table)).not.toBe(firstRowBefore) - await expect(bodyRows.first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() +test('renders each column at its declared size', async ({ page }) => { + const errors = await openExample(page) + + for (const [index, column] of LEAF_COLUMNS.entries()) { + const expected = INITIAL_SIZES[column] ?? 0 + await expect(sizeInput(page, column)).toHaveValue(String(expected)) + // Layout rounds, so allow a pixel of slack rather than an exact match. + expect(Math.abs((await widthOf(page, index)) - expected)).toBeLessThan(2) } + + expect(errors).toEqual([]) +}) + +test('resizes a column from the size panel', async ({ page }) => { + const errors = await openExample(page) + const before = await widthOf(page, 0) + + await sizeInput(page, 'firstName').fill('300') + + await expect.poll(() => readColumnSizing(page)).toEqual({ firstName: 300 }) + await expect.poll(() => widthOf(page, 0)).toBeGreaterThan(before + 100) + expect(Math.abs((await widthOf(page, 0)) - 300)).toBeLessThan(2) + + // Its neighbours keep their declared widths. + expect(Math.abs((await widthOf(page, 2)) - 100)).toBeLessThan(2) + + expect(errors).toEqual([]) +}) + +test('keeps all three table renderings the same width', async ({ page }) => { + const errors = await openExample(page) + + await sizeInput(page, 'status').fill('260') + + await expect.poll(() => readColumnSizing(page)).toEqual({ status: 260 }) + + // The native table, the relative div table and the absolutely positioned div + // table all read the same column size, so a change has to land in all three. + const native = await nativeHeader(page, 4).boundingBox() + const relative = await relativeDivHeader(page, 4).boundingBox() + const absolute = await absoluteDivHeader(page, 4).boundingBox() + + expect(Math.abs((native?.width ?? 0) - 260)).toBeLessThan(2) + expect(Math.abs((relative?.width ?? 0) - 260)).toBeLessThan(2) + expect(Math.abs((absolute?.width ?? 0) - 260)).toBeLessThan(2) + + expect(errors).toEqual([]) +}) + +test('keeps column sizes when data is regenerated', async ({ page }) => { + const errors = await openExample(page) + + await sizeInput(page, 'age').fill('180') + await expect.poll(() => readColumnSizing(page)).toEqual({ age: 180 }) + + // Sizing lives in table state, so replacing the rows must not reset it. + await page.getByRole('button', { name: /^Regenerate Data$/i }).click() + + await expect(page.locator('tbody tr')).toHaveCount(20) + expect(await readColumnSizing(page)).toEqual({ age: 180 }) + expect(Math.abs((await widthOf(page, 2)) - 180)).toBeLessThan(2) + + expect(errors).toEqual([]) }) diff --git a/examples/react/column-visibility/src/main.tsx b/examples/react/column-visibility/src/main.tsx index 8a90305995..6d44995a56 100644 --- a/examples/react/column-visibility/src/main.tsx +++ b/examples/react/column-visibility/src/main.tsx @@ -161,7 +161,9 @@ function App() {
-
{JSON.stringify(table.state, null, 2)}
+
+        {JSON.stringify(table.state, null, 2)}
+      
) } diff --git a/examples/react/column-visibility/tests/e2e/smoke.spec.ts b/examples/react/column-visibility/tests/e2e/smoke.spec.ts index 2eb199bf28..4741f78eaa 100644 --- a/examples/react/column-visibility/tests/e2e/smoke.spec.ts +++ b/examples/react/column-visibility/tests/e2e/smoke.spec.ts @@ -1,10 +1,42 @@ -import { expect, test } from '@playwright/test' -import type { Locator, Page } from '@playwright/test' import path from 'node:path' +import { expect, test } from '@playwright/test' import { startExampleServer } from '../../../../../tests/e2e/helpers/startExampleServer' +import type { Page } from '@playwright/test' const exampleDir = path.resolve() +/** Leaf columns in render order. The toggle panel labels each by column id. */ +const LEAF_COLUMNS = [ + 'firstName', + 'lastName', + 'age', + 'visits', + 'status', + 'progress', +] + +/** Leaf header text differs from column id wherever a custom header is set. */ +const LEAF_HEADERS = [ + 'firstName', + 'Last Name', + 'Age', + 'Visits', + 'Status', + 'Profile Progress', +] + +let server: Awaited> | undefined + +test.beforeAll(async () => { + // One server for the whole file. A cold Vite start costs more than a test. + test.setTimeout(180_000) + server = await startExampleServer(exampleDir) +}) + +test.afterAll(async () => { + await server?.close() +}) + function collectPageErrors(page: Page) { const errors: Array = [] @@ -22,7 +54,8 @@ function collectPageErrors(page: Page) { } async function openExample(page: Page) { - const server = await startExampleServer(exampleDir) + if (!server) throw new Error('Example server failed to start') + const errors = collectPageErrors(page) await page.route( @@ -36,51 +69,203 @@ async function openExample(page: Page) { await page.goto(server.url) - return { errors, server } + return errors } -async function getFirstBodyRowText(table: Locator) { - const text = await table.locator('tbody tr').first().textContent() +function columnToggle(page: Page, label: string) { + return page.getByLabel(label, { exact: true }) +} + +/** Header rows run group to leaf, so the leaf row is last. */ +function leafHeaderCells(page: Page) { + return page.locator('thead tr').last().locator('th') +} + +/** Footer groups are the header groups reversed, so the leaf row is first. */ +function leafFooterCells(page: Page) { + return page.locator('tfoot tr').first().locator('th') +} + +function groupHeaderCells(page: Page) { + return page.locator('thead tr').first().locator('th') +} + +function firstBodyRowCells(page: Page) { + return page.locator('tbody tr').first().locator('td') +} + +/** Row data is random faker output, so `table.state` is the stable oracle. */ +async function readColumnVisibility(page: Page) { + const text = await page.getByTestId('table-state').textContent() + const state = JSON.parse(text ?? '{}') as { + columnVisibility?: Record + } + + return state.columnVisibility ?? {} +} + +async function expectColumnVisibility( + page: Page, + expected: Record, +) { + await expect.poll(() => readColumnVisibility(page)).toEqual(expected) +} + +async function getFirstBodyRowText(page: Page) { + const text = await page.locator('tbody tr').first().textContent() return text?.replace(/\s+/g, ' ').trim() ?? '' } test('renders the table without crashing', async ({ page }) => { - const { errors, server } = await openExample(page) + const errors = await openExample(page) + const table = page.locator('table').first() - try { - const table = page.locator('table').first() + await expect(table).toBeVisible() + // Two nested column groups produce three header rows. + await expect(page.locator('thead tr')).toHaveCount(3) + await expect(groupHeaderCells(page)).toHaveText(['Name', 'Info']) + await expect(leafHeaderCells(page)).toHaveText(LEAF_HEADERS) + await expect(leafFooterCells(page)).toHaveText(LEAF_COLUMNS) + await expect(page.locator('tbody tr')).toHaveCount(20) + await expect(firstBodyRowCells(page)).toHaveCount(6) + await expectColumnVisibility(page, {}) - await expect(table).toBeVisible() - await expect(table.locator('thead th').first()).toBeVisible() - await expect(table.locator('tbody tr').first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() - } + expect(errors).toEqual([]) }) test('regenerates table data', async ({ page }) => { - const { errors, server } = await openExample(page) + const errors = await openExample(page) + const regenerateButton = page.getByRole('button', { + name: /^Regenerate Data$/i, + }) + + await expect(page.locator('tbody tr').first()).toBeVisible() + + const firstRowBefore = await getFirstBodyRowText(page) - try { - const table = page.locator('table').first() - const bodyRows = table.locator('tbody tr') - const regenerateButton = page.getByRole('button', { - name: /^Regenerate Data$/i, - }) + await regenerateButton.click() - await expect(table).toBeVisible() - await expect(bodyRows.first()).toBeVisible() - await expect(regenerateButton).toBeVisible() + await expect.poll(() => getFirstBodyRowText(page)).not.toBe(firstRowBefore) + await expect(page.locator('tbody tr')).toHaveCount(20) - const firstRowBefore = await getFirstBodyRowText(table) + expect(errors).toEqual([]) +}) + +test('renders a toggle for every leaf column', async ({ page }) => { + const errors = await openExample(page) + + // One checkbox per leaf column plus the panel's Toggle All. + await expect(page.locator('.column-toggle-panel input')).toHaveCount(7) + await expect(page.locator('.column-toggle-row label')).toHaveText( + LEAF_COLUMNS, + ) - await regenerateButton.click() + await expect(columnToggle(page, 'Toggle All')).toBeChecked() - await expect.poll(() => getFirstBodyRowText(table)).not.toBe(firstRowBefore) - await expect(bodyRows.first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() + for (const column of LEAF_COLUMNS) { + await expect(columnToggle(page, column)).toBeChecked() } + + expect(errors).toEqual([]) +}) + +test('hides and shows an individual column', async ({ page }) => { + const errors = await openExample(page) + + await columnToggle(page, 'visits').uncheck() + + await expectColumnVisibility(page, { visits: false }) + await expect(leafHeaderCells(page)).toHaveText( + LEAF_HEADERS.filter((header) => header !== 'Visits'), + ) + await expect(firstBodyRowCells(page)).toHaveCount(5) + await expect(leafFooterCells(page)).toHaveCount(5) + // Toggle All is driven by `getIsAllColumnsVisible`, so it clears too. + await expect(columnToggle(page, 'Toggle All')).not.toBeChecked() + + await columnToggle(page, 'visits').check() + + // Re-showing records `true` rather than dropping the key. + await expectColumnVisibility(page, { visits: true }) + await expect(leafHeaderCells(page)).toHaveText(LEAF_HEADERS) + await expect(firstBodyRowCells(page)).toHaveCount(6) + await expect(columnToggle(page, 'Toggle All')).toBeChecked() + + expect(errors).toEqual([]) +}) + +test('removes a header group when all of its columns are hidden', async ({ + page, +}) => { + const errors = await openExample(page) + + await expect(groupHeaderCells(page)).toHaveText(['Name', 'Info']) + + await columnToggle(page, 'firstName').uncheck() + await expect(groupHeaderCells(page)).toHaveText(['Name', 'Info']) + + // The group header only disappears once its last child is hidden. + await columnToggle(page, 'lastName').uncheck() + await expect(groupHeaderCells(page)).toHaveText(['Info']) + await expect(leafHeaderCells(page)).toHaveText([ + 'Age', + 'Visits', + 'Status', + 'Profile Progress', + ]) + await expect(firstBodyRowCells(page)).toHaveCount(4) + + await columnToggle(page, 'firstName').check() + await expect(groupHeaderCells(page)).toHaveText(['Name', 'Info']) + + expect(errors).toEqual([]) +}) + +test('hides and shows every column with Toggle All', async ({ page }) => { + const errors = await openExample(page) + + await columnToggle(page, 'Toggle All').uncheck() + + await expectColumnVisibility( + page, + Object.fromEntries(LEAF_COLUMNS.map((column) => [column, false])), + ) + await expect(leafHeaderCells(page)).toHaveCount(0) + await expect(firstBodyRowCells(page)).toHaveCount(0) + // The rows survive; they simply have no visible cells left to render. + await expect(page.locator('tbody tr')).toHaveCount(20) + + for (const column of LEAF_COLUMNS) { + await expect(columnToggle(page, column)).not.toBeChecked() + } + + await columnToggle(page, 'Toggle All').check() + + await expectColumnVisibility( + page, + Object.fromEntries(LEAF_COLUMNS.map((column) => [column, true])), + ) + await expect(leafHeaderCells(page)).toHaveText(LEAF_HEADERS) + await expect(firstBodyRowCells(page)).toHaveCount(6) + + expect(errors).toEqual([]) +}) + +test('keeps column visibility when data is regenerated', async ({ page }) => { + const errors = await openExample(page) + + await columnToggle(page, 'age').uncheck() + await expect(firstBodyRowCells(page)).toHaveCount(5) + + // Visibility lives in table state, so replacing the rows must not reset it. + await page.getByRole('button', { name: /^Regenerate Data$/i }).click() + + await expectColumnVisibility(page, { age: false }) + await expect(columnToggle(page, 'age')).not.toBeChecked() + await expect(firstBodyRowCells(page)).toHaveCount(5) + await expect(leafHeaderCells(page)).toHaveText( + LEAF_HEADERS.filter((header) => header !== 'Age'), + ) + + expect(errors).toEqual([]) }) diff --git a/examples/react/custom-plugin/src/main.tsx b/examples/react/custom-plugin/src/main.tsx index 05a9956a1c..3a58ca25d0 100644 --- a/examples/react/custom-plugin/src/main.tsx +++ b/examples/react/custom-plugin/src/main.tsx @@ -354,7 +354,9 @@ function App() { Showing {table.getRowModel().rows.length.toLocaleString()} of{' '} {table.getRowCount().toLocaleString()} Rows
-
{JSON.stringify(table.state, null, 2)}
+
+        {JSON.stringify(table.state, null, 2)}
+      
) } diff --git a/examples/react/expanding/src/main.tsx b/examples/react/expanding/src/main.tsx index a528b485bb..ac46ab7f11 100644 --- a/examples/react/expanding/src/main.tsx +++ b/examples/react/expanding/src/main.tsx @@ -215,7 +215,7 @@ function App() {
{table.getRowModel().rows.length.toLocaleString()} Rows
-
{JSON.stringify(table.state, null, 2)}
+
+        {JSON.stringify(table.state, null, 2)}
+      
) } diff --git a/examples/react/expanding/tests/e2e/smoke.spec.ts b/examples/react/expanding/tests/e2e/smoke.spec.ts index 2eb199bf28..5dce0e5916 100644 --- a/examples/react/expanding/tests/e2e/smoke.spec.ts +++ b/examples/react/expanding/tests/e2e/smoke.spec.ts @@ -1,10 +1,26 @@ -import { expect, test } from '@playwright/test' -import type { Locator, Page } from '@playwright/test' import path from 'node:path' +import { expect, test } from '@playwright/test' import { startExampleServer } from '../../../../../tests/e2e/helpers/startExampleServer' +import type { Page } from '@playwright/test' const exampleDir = path.resolve() +/** `makeData(100, 5, 3)`: 100 roots, 5 children each, 3 grandchildren each. */ +const CHILDREN_PER_ROW = 5 +const PAGE_SIZE = 10 + +let server: Awaited> | undefined + +test.beforeAll(async () => { + // One server for the whole file. A cold Vite start costs more than a test. + test.setTimeout(180_000) + server = await startExampleServer(exampleDir) +}) + +test.afterAll(async () => { + await server?.close() +}) + function collectPageErrors(page: Page) { const errors: Array = [] @@ -22,7 +38,8 @@ function collectPageErrors(page: Page) { } async function openExample(page: Page) { - const server = await startExampleServer(exampleDir) + if (!server) throw new Error('Example server failed to start') + const errors = collectPageErrors(page) await page.route( @@ -36,51 +53,185 @@ async function openExample(page: Page) { await page.goto(server.url) - return { errors, server } + return errors +} + +function bodyRows(page: Page) { + return page.locator('tbody tr') +} + +/** The expander lives in each row's first name cell, beside the checkbox. */ +function rowExpander(page: Page, index: number) { + return bodyRows(page).nth(index).getByRole('button') +} + +/** The wrapper whose padding encodes the row's depth. */ +function rowIndent(page: Page, index: number) { + return bodyRows(page).nth(index).locator('td').nth(1).locator('div').first() +} + +/** The expand-all control sits in the first name header. */ +function expandAllButton(page: Page) { + return page.locator('thead th').nth(1).getByRole('button') +} + +function rowCheckbox(page: Page, index: number) { + return bodyRows(page).nth(index).locator('input[type="checkbox"]').first() +} + +/** Row data is random faker output, so `table.state` is the stable oracle. */ +async function readState(page: Page) { + const text = await page.getByTestId('table-state').textContent() + + return JSON.parse(text ?? '{}') as { + expanded?: Record | true + rowSelection?: Record + } +} + +async function countExpanded(page: Page) { + const expanded = (await readState(page)).expanded + return expanded === true ? -1 : Object.keys(expanded ?? {}).length } -async function getFirstBodyRowText(table: Locator) { - const text = await table.locator('tbody tr').first().textContent() +async function countSelected(page: Page) { + return Object.keys((await readState(page)).rowSelection ?? {}).length +} + +async function getRowText(page: Page, index: number) { + const text = await bodyRows(page).nth(index).textContent() return text?.replace(/\s+/g, ' ').trim() ?? '' } test('renders the table without crashing', async ({ page }) => { - const { errors, server } = await openExample(page) + const errors = await openExample(page) + const table = page.locator('table').first() - try { - const table = page.locator('table').first() + await expect(table).toBeVisible() + await expect(bodyRows(page)).toHaveCount(PAGE_SIZE) + await expect.poll(async () => (await readState(page)).expanded).toEqual({}) + // Nothing is expanded yet, so every visible row is a root row. + await expect(rowExpander(page, 0)).toHaveText('👉') + await expect(rowIndent(page, 0)).toHaveCSS('padding-left', '0px') - await expect(table).toBeVisible() - await expect(table.locator('thead th').first()).toBeVisible() - await expect(table.locator('tbody tr').first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() - } + expect(errors).toEqual([]) }) test('regenerates table data', async ({ page }) => { - const { errors, server } = await openExample(page) + const errors = await openExample(page) + const regenerateButton = page.getByRole('button', { + name: /^Regenerate Data$/i, + }) - try { - const table = page.locator('table').first() - const bodyRows = table.locator('tbody tr') - const regenerateButton = page.getByRole('button', { - name: /^Regenerate Data$/i, - }) + await expect(bodyRows(page).first()).toBeVisible() - await expect(table).toBeVisible() - await expect(bodyRows.first()).toBeVisible() - await expect(regenerateButton).toBeVisible() + const firstRowBefore = await getRowText(page, 0) - const firstRowBefore = await getFirstBodyRowText(table) + await regenerateButton.click() - await regenerateButton.click() + await expect.poll(() => getRowText(page, 0)).not.toBe(firstRowBefore) + await expect(bodyRows(page)).toHaveCount(PAGE_SIZE) - await expect.poll(() => getFirstBodyRowText(table)).not.toBe(firstRowBefore) - await expect(bodyRows.first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() - } + expect(errors).toEqual([]) +}) + +test('expands and collapses a single row', async ({ page }) => { + const errors = await openExample(page) + const secondRowBefore = await getRowText(page, 1) + + await rowExpander(page, 0).click() + + await expect(rowExpander(page, 0)).toHaveText('👇') + await expect.poll(() => countExpanded(page)).toBe(1) + + // Children are spliced in beneath their parent, but pagination still hands + // back one page, so the rows below are pushed out of the window instead. + await expect(bodyRows(page)).toHaveCount(PAGE_SIZE) + expect(await getRowText(page, 1)).not.toBe(secondRowBefore) + await expect(rowIndent(page, 1)).toHaveCSS('padding-left', '28px') + + await rowExpander(page, 0).click() + + await expect(rowExpander(page, 0)).toHaveText('👉') + await expect.poll(() => countExpanded(page)).toBe(0) + // Collapsing restores exactly the row that was displaced. + expect(await getRowText(page, 1)).toBe(secondRowBefore) + + expect(errors).toEqual([]) +}) + +test('indents each level of sub rows', async ({ page }) => { + const errors = await openExample(page) + + await rowExpander(page, 0).click() + await expect(rowExpander(page, 0)).toHaveText('👇') + + // Depth drives a 2rem indent per level, and this example sets the root font + // size to 14px, so each level adds 28px. + await expect(rowIndent(page, 0)).toHaveCSS('padding-left', '0px') + await expect(rowIndent(page, 1)).toHaveCSS('padding-left', '28px') + + await rowExpander(page, 1).click() + + await expect(rowExpander(page, 1)).toHaveText('👇') + await expect(rowIndent(page, 2)).toHaveCSS('padding-left', '56px') + await expect.poll(() => countExpanded(page)).toBe(2) + + expect(errors).toEqual([]) +}) + +test('expands every row from the header', async ({ page }) => { + const errors = await openExample(page) + + await expect(expandAllButton(page)).toHaveText('👉') + + await expandAllButton(page).click() + + await expect(expandAllButton(page)).toHaveText('👇') + // Expanding everything is stored as `true` rather than a map of row ids. + await expect.poll(async () => (await readState(page)).expanded).toBe(true) + // The first page is now a root followed by its own descendants. + await expect(rowIndent(page, 1)).toHaveCSS('padding-left', '28px') + await expect(rowIndent(page, 2)).toHaveCSS('padding-left', '56px') + + await expandAllButton(page).click() + + await expect(expandAllButton(page)).toHaveText('👉') + await expect(rowIndent(page, 1)).toHaveCSS('padding-left', '0px') + + expect(errors).toEqual([]) +}) + +test('marks rows without children as leaves', async ({ page }) => { + const errors = await openExample(page) + + await expandAllButton(page).click() + await expect(expandAllButton(page)).toHaveText('👇') + + // The third level has no children of its own, so it renders a leaf marker + // instead of an expander. + await expect(page.locator('tbody').getByText('🔵').first()).toBeVisible() + + expect(errors).toEqual([]) +}) + +test('selects sub rows along with their parent', async ({ page }) => { + const errors = await openExample(page) + + await rowExpander(page, 0).click() + await expect(rowExpander(page, 0)).toHaveText('👇') + + await rowCheckbox(page, 0).check() + + // Selecting a parent cascades to its whole subtree, so one click selects far + // more than one row. + await expect.poll(() => countSelected(page)).toBeGreaterThan(CHILDREN_PER_ROW) + await expect(rowCheckbox(page, 1)).toBeChecked() + + await rowCheckbox(page, 0).uncheck() + + await expect.poll(() => countSelected(page)).toBe(0) + await expect(rowCheckbox(page, 1)).not.toBeChecked() + + expect(errors).toEqual([]) }) diff --git a/examples/react/filters-faceted-bucketed/src/main.tsx b/examples/react/filters-faceted-bucketed/src/main.tsx index 1d3d0b2e59..917b8aeeb8 100644 --- a/examples/react/filters-faceted-bucketed/src/main.tsx +++ b/examples/react/filters-faceted-bucketed/src/main.tsx @@ -246,7 +246,7 @@ function App() {
-
{JSON.stringify(table.state, null, 2)}
+
+        {JSON.stringify(table.state, null, 2)}
+      
) } diff --git a/examples/react/filters-faceted/src/main.tsx b/examples/react/filters-faceted/src/main.tsx index 68cdecb4c7..56ff7f0e42 100644 --- a/examples/react/filters-faceted/src/main.tsx +++ b/examples/react/filters-faceted/src/main.tsx @@ -167,7 +167,7 @@ function App() {
-
{JSON.stringify(table.state, null, 2)}
+
+        {JSON.stringify(table.state, null, 2)}
+      
) } diff --git a/examples/react/filters-fuzzy/src/main.tsx b/examples/react/filters-fuzzy/src/main.tsx index 9ca37dfe9c..1ec05335b9 100644 --- a/examples/react/filters-fuzzy/src/main.tsx +++ b/examples/react/filters-fuzzy/src/main.tsx @@ -238,7 +238,7 @@ function App() {
-
{JSON.stringify(table.state, null, 2)}
+
+        {JSON.stringify(table.state, null, 2)}
+      
) } diff --git a/examples/react/filters/src/main.tsx b/examples/react/filters/src/main.tsx index b4bfa12353..230e598764 100644 --- a/examples/react/filters/src/main.tsx +++ b/examples/react/filters/src/main.tsx @@ -167,7 +167,7 @@ function App() {
-
{JSON.stringify(table.state, null, 2)}
+
+        {JSON.stringify(table.state, null, 2)}
+      
) } diff --git a/examples/react/filters/tests/e2e/smoke.spec.ts b/examples/react/filters/tests/e2e/smoke.spec.ts index 2eb199bf28..110155e473 100644 --- a/examples/react/filters/tests/e2e/smoke.spec.ts +++ b/examples/react/filters/tests/e2e/smoke.spec.ts @@ -1,10 +1,38 @@ -import { expect, test } from '@playwright/test' -import type { Locator, Page } from '@playwright/test' import path from 'node:path' +import { expect, test } from '@playwright/test' import { startExampleServer } from '../../../../../tests/e2e/helpers/startExampleServer' +import type { Page } from '@playwright/test' const exampleDir = path.resolve() +const TOTAL_ROWS = 5_000 + +/** Header cell index per column, matching the column order in `src/main.tsx`. */ +const COLUMN = { + rowNumber: 0, + firstName: 1, + lastName: 2, + fullName: 3, + age: 4, + visits: 5, + status: 6, + progress: 7, +} as const + +const STATUSES = ['complicated', 'relationship', 'single'] + +let server: Awaited> | undefined + +test.beforeAll(async () => { + // One server for the whole file. A cold Vite start costs more than a test. + test.setTimeout(180_000) + server = await startExampleServer(exampleDir) +}) + +test.afterAll(async () => { + await server?.close() +}) + function collectPageErrors(page: Page) { const errors: Array = [] @@ -22,7 +50,8 @@ function collectPageErrors(page: Page) { } async function openExample(page: Page) { - const server = await startExampleServer(exampleDir) + if (!server) throw new Error('Example server failed to start') + const errors = collectPageErrors(page) await page.route( @@ -36,51 +65,280 @@ async function openExample(page: Page) { await page.goto(server.url) - return { errors, server } + return errors +} + +function headerCell(page: Page, column: keyof typeof COLUMN) { + return page.locator('thead th').nth(COLUMN[column]) +} + +function textFilter(page: Page, column: 'firstName' | 'lastName' | 'fullName') { + return headerCell(page, column).locator('input[type="text"]') +} + +function rangeFilter( + page: Page, + column: 'age' | 'visits' | 'progress', + bound: 'Min' | 'Max', +) { + return headerCell(page, column).getByPlaceholder(bound) +} + +function statusFilter(page: Page) { + return headerCell(page, 'status').locator('select') +} + +/** The pre-paginated count, so it reflects filtering but not the page window. */ +function rowCountLine(page: Page) { + return page.locator('div').filter({ hasText: /^[\d,]+ Rows$/ }) +} + +async function readFilteredRowCount(page: Page) { + const text = await rowCountLine(page).textContent() + return Number((text ?? '').replace(/\D/g, '')) +} + +/** Row data is random faker output, so `table.state` is the stable oracle. */ +async function readState(page: Page) { + const text = await page.getByTestId('table-state').textContent() + + return JSON.parse(text ?? '{}') as { + columnFilters?: Array<{ id: string; value: unknown }> + pagination?: { pageIndex: number; pageSize: number } + } +} + +async function readColumnFilters(page: Page) { + return (await readState(page)).columnFilters ?? [] } -async function getFirstBodyRowText(table: Locator) { - const text = await table.locator('tbody tr').first().textContent() +async function expectColumnFilters( + page: Page, + expected: Array<{ id: string; value: unknown }>, +) { + // Every filter control debounces by 500ms, so poll rather than sleeping. + await expect.poll(() => readColumnFilters(page)).toEqual(expected) +} + +async function readBodyColumn(page: Page, column: keyof typeof COLUMN) { + const cells = await page + .locator(`tbody tr td:nth-child(${COLUMN[column] + 1})`) + .allTextContents() + + return cells.map((cell) => cell.trim()) +} + +async function getFirstBodyRowText(page: Page) { + const text = await page.locator('tbody tr').first().textContent() return text?.replace(/\s+/g, ' ').trim() ?? '' } test('renders the table without crashing', async ({ page }) => { - const { errors, server } = await openExample(page) + const errors = await openExample(page) + const table = page.locator('table').first() - try { - const table = page.locator('table').first() + await expect(table).toBeVisible() + await expect(table.locator('thead th')).toHaveCount(8) + await expect(page.locator('tbody tr')).toHaveCount(10) + await expect(rowCountLine(page)).toHaveText('5,000 Rows') + await expectColumnFilters(page, []) - await expect(table).toBeVisible() - await expect(table.locator('thead th').first()).toBeVisible() - await expect(table.locator('tbody tr').first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() - } + expect(errors).toEqual([]) }) test('regenerates table data', async ({ page }) => { - const { errors, server } = await openExample(page) + const errors = await openExample(page) + const regenerateButton = page.getByRole('button', { + name: /^Regenerate Data$/i, + }) + + await expect(page.locator('tbody tr').first()).toBeVisible() + + const firstRowBefore = await getFirstBodyRowText(page) - try { - const table = page.locator('table').first() - const bodyRows = table.locator('tbody tr') - const regenerateButton = page.getByRole('button', { - name: /^Regenerate Data$/i, - }) + await regenerateButton.click() - await expect(table).toBeVisible() - await expect(bodyRows.first()).toBeVisible() - await expect(regenerateButton).toBeVisible() + await expect.poll(() => getFirstBodyRowText(page)).not.toBe(firstRowBefore) + await expect(page.locator('tbody tr')).toHaveCount(10) - const firstRowBefore = await getFirstBodyRowText(table) + expect(errors).toEqual([]) +}) + +test('renders a filter control for every filterable column', async ({ + page, +}) => { + const errors = await openExample(page) - await regenerateButton.click() + // Three text columns, three range columns with a Min and a Max, one select. + await expect(page.locator('thead input[type="text"]')).toHaveCount(3) + await expect(page.locator('thead input[type="number"]')).toHaveCount(6) + await expect(page.locator('thead select')).toHaveCount(1) + + await expect(textFilter(page, 'firstName')).toHaveAttribute( + 'placeholder', + 'Search...', + ) + await expect(statusFilter(page).locator('option')).toHaveText([ + 'All', + ...STATUSES, + ]) + + // The row number column has no accessor, so it cannot be filtered. + await expect( + headerCell(page, 'rowNumber').locator('input, select'), + ).toHaveCount(0) + + expect(errors).toEqual([]) +}) - await expect.poll(() => getFirstBodyRowText(table)).not.toBe(firstRowBefore) - await expect(bodyRows.first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() +test('filters by text after the debounce', async ({ page }) => { + const errors = await openExample(page) + + // A fragment of a name that is actually present, so a match is guaranteed + // even though the data is regenerated randomly on every load. + const firstNames = await readBodyColumn(page, 'firstName') + const fragment = (firstNames[0] ?? '').slice(0, 3).toLowerCase() + expect(fragment).not.toBe('') + + await textFilter(page, 'firstName').fill(fragment) + + await expectColumnFilters(page, [{ id: 'firstName', value: fragment }]) + + const filtered = await readFilteredRowCount(page) + expect(filtered).toBeGreaterThan(0) + expect(filtered).toBeLessThan(TOTAL_ROWS) + + for (const name of await readBodyColumn(page, 'firstName')) { + expect(name.toLowerCase()).toContain(fragment) } + + // Emptying a text filter drops it from state rather than keeping a blank. + await textFilter(page, 'firstName').fill('') + + await expectColumnFilters(page, []) + await expect(rowCountLine(page)).toHaveText('5,000 Rows') + + expect(errors).toEqual([]) +}) + +test('waits for the debounce before applying the filter', async ({ page }) => { + const errors = await openExample(page) + + await textFilter(page, 'firstName').fill('zzz') + + // The 500ms debounce has not elapsed, so state is still untouched. + expect(await readColumnFilters(page)).toEqual([]) + + await expectColumnFilters(page, [{ id: 'firstName', value: 'zzz' }]) + + expect(errors).toEqual([]) +}) + +test('filters a numeric column by range', async ({ page }) => { + const errors = await openExample(page) + + await rangeFilter(page, 'age', 'Min').fill('20') + + // An unset bound serialises as null, leaving the range open ended. + await expectColumnFilters(page, [{ id: 'age', value: ['20', null] }]) + + const minOnly = await readFilteredRowCount(page) + expect(minOnly).toBeLessThan(TOTAL_ROWS) + + for (const age of await readBodyColumn(page, 'age')) { + expect(Number(age)).toBeGreaterThanOrEqual(20) + } + + await rangeFilter(page, 'age', 'Max').fill('25') + + await expectColumnFilters(page, [{ id: 'age', value: ['20', '25'] }]) + expect(await readFilteredRowCount(page)).toBeLessThan(minOnly) + + for (const age of await readBodyColumn(page, 'age')) { + expect(Number(age)).toBeGreaterThanOrEqual(20) + expect(Number(age)).toBeLessThanOrEqual(25) + } + + // Clearing one bound keeps the filter, with that end left empty. + await rangeFilter(page, 'age', 'Min').fill('') + + await expectColumnFilters(page, [{ id: 'age', value: ['', '25'] }]) + + for (const age of await readBodyColumn(page, 'age')) { + expect(Number(age)).toBeLessThanOrEqual(25) + } + + await rangeFilter(page, 'age', 'Max').fill('') + + await expect.poll(() => readFilteredRowCount(page)).toBe(TOTAL_ROWS) + + expect(errors).toEqual([]) +}) + +test('filters by the status select', async ({ page }) => { + const errors = await openExample(page) + const counts: Array = [] + + for (const status of STATUSES) { + await statusFilter(page).selectOption(status) + + await expectColumnFilters(page, [{ id: 'status', value: status }]) + + for (const cell of await readBodyColumn(page, 'status')) { + expect(cell).toBe(status) + } + + counts.push(await readFilteredRowCount(page)) + } + + // Every row has exactly one of the three statuses, so the parts must make up + // the whole. This holds no matter what the random data happens to be. + expect(counts.reduce((total, count) => total + count, 0)).toBe(TOTAL_ROWS) + + // The All option carries an empty value, which removes the filter entirely. + await statusFilter(page).selectOption('') + + await expectColumnFilters(page, []) + await expect(rowCountLine(page)).toHaveText('5,000 Rows') + + expect(errors).toEqual([]) +}) + +test('combines filters and returns to the first page', async ({ page }) => { + const errors = await openExample(page) + + await page.getByRole('button', { name: '>', exact: true }).click() + await page.getByRole('button', { name: '>', exact: true }).click() + await expect + .poll(async () => (await readState(page)).pagination?.pageIndex) + .toBe(2) + + await statusFilter(page).selectOption('single') + + await expectColumnFilters(page, [{ id: 'status', value: 'single' }]) + // `autoResetPageIndex` defaults to true, so filtering rewinds to page one. + await expect + .poll(async () => (await readState(page)).pagination?.pageIndex) + .toBe(0) + + const statusOnly = await readFilteredRowCount(page) + + await rangeFilter(page, 'age', 'Min').fill('20') + + await expectColumnFilters(page, [ + { id: 'status', value: 'single' }, + { id: 'age', value: ['20', null] }, + ]) + // Filters intersect, so adding one can only narrow the result. + expect(await readFilteredRowCount(page)).toBeLessThanOrEqual(statusOnly) + + for (const cell of await readBodyColumn(page, 'status')) { + expect(cell).toBe('single') + } + + for (const age of await readBodyColumn(page, 'age')) { + expect(Number(age)).toBeGreaterThanOrEqual(20) + } + + expect(errors).toEqual([]) }) diff --git a/examples/react/grouped-aggregation/src/main.tsx b/examples/react/grouped-aggregation/src/main.tsx index fcbc460cab..9a9f749422 100644 --- a/examples/react/grouped-aggregation/src/main.tsx +++ b/examples/react/grouped-aggregation/src/main.tsx @@ -236,7 +236,7 @@ function App() {
{table.getRowModel().rows.length.toLocaleString()} Rows
-
{JSON.stringify(table.state, null, 2)}
+
+        {JSON.stringify(table.state, null, 2)}
+      
) } diff --git a/examples/react/grouping/src/main.tsx b/examples/react/grouping/src/main.tsx index 008cf9cdc5..435c6cb8cf 100644 --- a/examples/react/grouping/src/main.tsx +++ b/examples/react/grouping/src/main.tsx @@ -184,7 +184,7 @@ function App() {
{table.getRowModel().rows.length.toLocaleString()} Rows
-
{JSON.stringify(table.state, null, 2)}
+
+        {JSON.stringify(table.state, null, 2)}
+      
) } diff --git a/examples/react/grouping/tests/e2e/smoke.spec.ts b/examples/react/grouping/tests/e2e/smoke.spec.ts index 51cf203b2f..f3369f811a 100644 --- a/examples/react/grouping/tests/e2e/smoke.spec.ts +++ b/examples/react/grouping/tests/e2e/smoke.spec.ts @@ -1,10 +1,25 @@ -import { expect, test } from '@playwright/test' -import type { Locator, Page } from '@playwright/test' import path from 'node:path' +import { expect, test } from '@playwright/test' import { startExampleServer } from '../../../../../tests/e2e/helpers/startExampleServer' +import type { Page } from '@playwright/test' const exampleDir = path.resolve() +const TOTAL_ROWS = 10_000 +const STATUS_COUNT = 3 + +let server: Awaited> | undefined + +test.beforeAll(async () => { + // One server for the whole file. A cold Vite start costs more than a test. + test.setTimeout(180_000) + server = await startExampleServer(exampleDir) +}) + +test.afterAll(async () => { + await server?.close() +}) + function collectPageErrors(page: Page) { const errors: Array = [] @@ -22,7 +37,8 @@ function collectPageErrors(page: Page) { } async function openExample(page: Page) { - const server = await startExampleServer(exampleDir) + if (!server) throw new Error('Example server failed to start') + const errors = collectPageErrors(page) await page.route( @@ -36,53 +52,167 @@ async function openExample(page: Page) { await page.goto(server.url) - return { errors, server } + return errors } -async function getFirstBodyRowText(table: Locator) { - const text = await table.locator('tbody tr').first().textContent() - return text?.replace(/\s+/g, ' ').trim() ?? '' +/** Each header carries a toggle: `👊` when ungrouped, `🛑(index)` when grouped. */ +function groupToggle(page: Page, header: string) { + return page + .locator('thead th') + .filter({ hasText: header }) + .getByRole('button') } -test('renders the table without crashing', async ({ page }) => { - const { errors, server } = await openExample(page) +function bodyRows(page: Page) { + return page.locator('tbody tr') +} - try { - const table = page.locator('table').first() +/** Row data is random faker output, so `table.state` is the stable oracle. */ +async function readState(page: Page) { + const text = await page.getByTestId('table-state').textContent() - await expect(table).toBeVisible() - await expect(table.locator('thead th').first()).toBeVisible() - await expect(table.locator('tbody tr').first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() + return JSON.parse(text ?? '{}') as { + grouping?: Array + expanded?: Record | true } +} + +async function expectGrouping(page: Page, expected: Array) { + await expect + .poll(async () => (await readState(page)).grouping ?? []) + .toEqual(expected) +} + +/** The `(1,234)` leaf count rendered inside each group row's expander. */ +async function readGroupCounts(page: Page) { + const texts = await bodyRows(page).locator('td button').allTextContents() + + return texts.map((text) => { + const match = text.match(/\(([\d,]+)\)/) + return match ? Number((match[1] ?? '0').replace(/,/g, '')) : 0 + }) +} + +async function getFirstBodyRowText(page: Page) { + const text = await bodyRows(page).first().textContent() + return text?.replace(/\s+/g, ' ').trim() ?? '' +} + +test('renders the table without crashing', async ({ page }) => { + const errors = await openExample(page) + const table = page.locator('table').first() + + await expect(table).toBeVisible() + await expect(table.locator('thead th')).toHaveCount(6) + await expect(bodyRows(page).first()).toBeVisible() + await expectGrouping(page, []) + // Every header offers a grouping toggle, all of them ungrouped. + await expect(page.locator('thead th button')).toHaveCount(6) + await expect(groupToggle(page, 'Status')).toHaveText('👊') + + expect(errors).toEqual([]) }) test('regenerates table data', async ({ page }) => { - const { errors, server } = await openExample(page) - - try { - const table = page.locator('table').first() - const bodyRows = table.locator('tbody tr') - const regenerateButton = page.getByRole('button', { - name: /^Regenerate Data$/i, - }) - - await expect(table).toBeVisible() - await expect(bodyRows.first()).toBeVisible() - await expect(table.locator('tfoot')).toHaveCount(0) - await expect(table).not.toContainText('Grand Total') - await expect(regenerateButton).toBeVisible() - - const firstRowBefore = await getFirstBodyRowText(table) - - await regenerateButton.click() - - await expect.poll(() => getFirstBodyRowText(table)).not.toBe(firstRowBefore) - await expect(bodyRows.first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() - } + const errors = await openExample(page) + const regenerateButton = page.getByRole('button', { + name: /^Regenerate Data$/i, + }) + + await expect(bodyRows(page).first()).toBeVisible() + + const firstRowBefore = await getFirstBodyRowText(page) + + await regenerateButton.click() + + await expect.poll(() => getFirstBodyRowText(page)).not.toBe(firstRowBefore) + + expect(errors).toEqual([]) +}) + +test('groups rows by a column', async ({ page }) => { + const errors = await openExample(page) + + await groupToggle(page, 'Status').click() + + await expectGrouping(page, ['status']) + // The toggle now reports this column's position in the grouping list. + await expect(groupToggle(page, 'Status')).toHaveText('🛑(0)') + + // Three statuses collapse ten thousand rows into three group rows. + await expect(bodyRows(page)).toHaveCount(STATUS_COUNT) + await expect(bodyRows(page).first()).toContainText('👉') + await expect(bodyRows(page).first()).toContainText(/\([\d,]+\)/) + + expect(errors).toEqual([]) +}) + +test('group leaf counts add up to every row', async ({ page }) => { + const errors = await openExample(page) + + await groupToggle(page, 'Status').click() + await expectGrouping(page, ['status']) + await expect(bodyRows(page)).toHaveCount(STATUS_COUNT) + + // Grouping partitions the rows, so the parts must make up the whole. This + // holds no matter what the random data happens to be. + const counts = await readGroupCounts(page) + expect(counts).toHaveLength(STATUS_COUNT) + expect(counts.reduce((total, count) => total + count, 0)).toBe(TOTAL_ROWS) + + expect(errors).toEqual([]) +}) + +test('expands a group to reveal its leaf rows', async ({ page }) => { + const errors = await openExample(page) + + await groupToggle(page, 'Status').click() + await expect(bodyRows(page)).toHaveCount(STATUS_COUNT) + + await bodyRows(page).first().locator('td button').first().click() + + await expect(bodyRows(page).first()).toContainText('👇') + // Leaf rows now sit under the expanded group, so the page fills up. + await expect.poll(() => bodyRows(page).count()).toBeGreaterThan(STATUS_COUNT) + + await bodyRows(page).first().locator('td button').first().click() + + await expect(bodyRows(page).first()).toContainText('👉') + await expect(bodyRows(page)).toHaveCount(STATUS_COUNT) + + expect(errors).toEqual([]) +}) + +test('nests a second grouping level', async ({ page }) => { + const errors = await openExample(page) + + await groupToggle(page, 'Status').click() + await expectGrouping(page, ['status']) + + await groupToggle(page, 'Age').click() + + await expectGrouping(page, ['status', 'age']) + await expect(groupToggle(page, 'Status')).toHaveText('🛑(0)') + await expect(groupToggle(page, 'Age')).toHaveText('🛑(1)') + // The outer grouping still decides the top level row count. + await expect(bodyRows(page)).toHaveCount(STATUS_COUNT) + + expect(errors).toEqual([]) +}) + +test('ungroups a column', async ({ page }) => { + const errors = await openExample(page) + + await groupToggle(page, 'Status').click() + await expectGrouping(page, ['status']) + await expect(bodyRows(page)).toHaveCount(STATUS_COUNT) + + await groupToggle(page, 'Status').click() + + await expectGrouping(page, []) + await expect(groupToggle(page, 'Status')).toHaveText('👊') + // Back to flat rows, so the page fills up again. + await expect(bodyRows(page)).toHaveCount(10) + + expect(errors).toEqual([]) }) diff --git a/examples/react/header-groups/tests/e2e/smoke.spec.ts b/examples/react/header-groups/tests/e2e/smoke.spec.ts index 2eb199bf28..fa68964bb2 100644 --- a/examples/react/header-groups/tests/e2e/smoke.spec.ts +++ b/examples/react/header-groups/tests/e2e/smoke.spec.ts @@ -1,10 +1,40 @@ -import { expect, test } from '@playwright/test' -import type { Locator, Page } from '@playwright/test' import path from 'node:path' +import { expect, test } from '@playwright/test' import { startExampleServer } from '../../../../../tests/e2e/helpers/startExampleServer' +import type { Page } from '@playwright/test' const exampleDir = path.resolve() +const LEAF_HEADERS = [ + 'firstName', + 'Last Name', + 'Age', + 'Visits', + 'Status', + 'Profile Progress', +] + +const LEAF_FOOTERS = [ + 'firstName', + 'lastName', + 'age', + 'visits', + 'status', + 'progress', +] + +let server: Awaited> | undefined + +test.beforeAll(async () => { + // One server for the whole file. A cold Vite start costs more than a test. + test.setTimeout(180_000) + server = await startExampleServer(exampleDir) +}) + +test.afterAll(async () => { + await server?.close() +}) + function collectPageErrors(page: Page) { const errors: Array = [] @@ -22,7 +52,8 @@ function collectPageErrors(page: Page) { } async function openExample(page: Page) { - const server = await startExampleServer(exampleDir) + if (!server) throw new Error('Example server failed to start') + const errors = collectPageErrors(page) await page.route( @@ -36,51 +67,112 @@ async function openExample(page: Page) { await page.goto(server.url) - return { errors, server } + return errors } -async function getFirstBodyRowText(table: Locator) { - const text = await table.locator('tbody tr').first().textContent() +function headerRow(page: Page, index: number) { + return page.locator('thead tr').nth(index).locator('th') +} + +function footerRow(page: Page, index: number) { + return page.locator('tfoot tr').nth(index).locator('th') +} + +async function readColSpans(page: Page, selector: string) { + return page + .locator(selector) + .evaluateAll((cells) => + cells.map((cell) => Number(cell.getAttribute('colspan') ?? '1')), + ) +} + +async function getFirstBodyRowText(page: Page) { + const text = await page.locator('tbody tr').first().textContent() return text?.replace(/\s+/g, ' ').trim() ?? '' } test('renders the table without crashing', async ({ page }) => { - const { errors, server } = await openExample(page) + const errors = await openExample(page) + const table = page.locator('table').first() - try { - const table = page.locator('table').first() + await expect(table).toBeVisible() + await expect(page.locator('tbody tr')).toHaveCount(20) + await expect(page.locator('tbody tr').first().locator('td')).toHaveCount( + LEAF_HEADERS.length, + ) - await expect(table).toBeVisible() - await expect(table.locator('thead th').first()).toBeVisible() - await expect(table.locator('tbody tr').first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() - } + expect(errors).toEqual([]) }) test('regenerates table data', async ({ page }) => { - const { errors, server } = await openExample(page) + const errors = await openExample(page) + const regenerateButton = page.getByRole('button', { + name: /^Regenerate Data$/i, + }) + + await expect(page.locator('tbody tr').first()).toBeVisible() + + const firstRowBefore = await getFirstBodyRowText(page) - try { - const table = page.locator('table').first() - const bodyRows = table.locator('tbody tr') - const regenerateButton = page.getByRole('button', { - name: /^Regenerate Data$/i, - }) + await regenerateButton.click() - await expect(table).toBeVisible() - await expect(bodyRows.first()).toBeVisible() - await expect(regenerateButton).toBeVisible() + await expect.poll(() => getFirstBodyRowText(page)).not.toBe(firstRowBefore) + await expect(page.locator('tbody tr')).toHaveCount(20) - const firstRowBefore = await getFirstBodyRowText(table) + expect(errors).toEqual([]) +}) + +test('nests the header groups three rows deep', async ({ page }) => { + const errors = await openExample(page) - await regenerateButton.click() + // Two top level groups, one of which nests a further group, so the deepest + // leaf needs three header rows. + await expect(page.locator('thead tr')).toHaveCount(3) - await expect.poll(() => getFirstBodyRowText(table)).not.toBe(firstRowBefore) - await expect(bodyRows.first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() + await expect(headerRow(page, 0)).toHaveText(['Hello', 'Info']) + // Row two is mostly placeholders for the columns that have no middle group. + await expect(headerRow(page, 1)).toHaveText(['', '', '', 'More Info']) + await expect(headerRow(page, 2)).toHaveText(LEAF_HEADERS) + + expect(errors).toEqual([]) +}) + +test('spans each group across the columns beneath it', async ({ page }) => { + const errors = await openExample(page) + + // `Hello` covers two leaves, `Info` covers the other four. + expect(await readColSpans(page, 'thead tr:nth-child(1) th')).toEqual([2, 4]) + // `More Info` covers the last three of `Info`'s four leaves. + expect(await readColSpans(page, 'thead tr:nth-child(2) th')).toEqual([ + 1, 1, 1, 3, + ]) + expect(await readColSpans(page, 'thead tr:nth-child(3) th')).toEqual([ + 1, 1, 1, 1, 1, 1, + ]) + + // Every header row must cover the same total width as the body rows. + for (const row of [1, 2, 3]) { + const spans = await readColSpans(page, `thead tr:nth-child(${row}) th`) + expect(spans.reduce((total, span) => total + span, 0)).toBe( + LEAF_HEADERS.length, + ) } + + expect(errors).toEqual([]) +}) + +test('mirrors the header groups in the footer', async ({ page }) => { + const errors = await openExample(page) + + // Footer groups are the header groups reversed, so leaves come first. + await expect(page.locator('tfoot tr')).toHaveCount(3) + await expect(footerRow(page, 0)).toHaveText(LEAF_FOOTERS) + + // The `hello` group declares a header but no footer, so its footer cell is + // empty, while `Info` declares both. The cell is still rendered and still + // spans its columns, which is what keeps the footer aligned with the body. + await expect(footerRow(page, 2)).toHaveText(['', 'Info']) + expect(await readColSpans(page, 'tfoot tr:nth-child(3) th')).toEqual([2, 4]) + + expect(errors).toEqual([]) }) diff --git a/examples/react/kitchen-sink/src/routes/index.tsx b/examples/react/kitchen-sink/src/routes/index.tsx index 09728e678d..f2f5798ae4 100644 --- a/examples/react/kitchen-sink/src/routes/index.tsx +++ b/examples/react/kitchen-sink/src/routes/index.tsx @@ -189,7 +189,9 @@ function IndeterminateCheckbox({ if (typeof indeterminate === 'boolean') { ref.current.indeterminate = !rest.checked && indeterminate } - }, [ref, indeterminate]) + // `checked` belongs here too: `getIsSomePageRowsSelected` stays true when + // every page row is selected, so deselecting one only changes `checked`. + }, [ref, indeterminate, rest.checked]) return } @@ -945,7 +947,7 @@ function App() {
Table state (live) -
+          
             {JSON.stringify(table.state, null, 2)}
           
diff --git a/examples/react/mantine-react-table/src/mantine-react-table/components/menus/MRT_ShowHideColumnsMenu.tsx b/examples/react/mantine-react-table/src/mantine-react-table/components/menus/MRT_ShowHideColumnsMenu.tsx index 9f60feb354..d142afec5a 100644 --- a/examples/react/mantine-react-table/src/mantine-react-table/components/menus/MRT_ShowHideColumnsMenu.tsx +++ b/examples/react/mantine-react-table/src/mantine-react-table/components/menus/MRT_ShowHideColumnsMenu.tsx @@ -85,7 +85,13 @@ export const MRT_ShowHideColumnsMenu = ({
-
{JSON.stringify(table.state, null, 2)}
+
+        {JSON.stringify(table.state, null, 2)}
+      
) } diff --git a/examples/react/pagination/tests/e2e/smoke.spec.ts b/examples/react/pagination/tests/e2e/smoke.spec.ts index 2eb199bf28..727abfafa2 100644 --- a/examples/react/pagination/tests/e2e/smoke.spec.ts +++ b/examples/react/pagination/tests/e2e/smoke.spec.ts @@ -1,10 +1,26 @@ -import { expect, test } from '@playwright/test' -import type { Locator, Page } from '@playwright/test' import path from 'node:path' +import { expect, test } from '@playwright/test' import { startExampleServer } from '../../../../../tests/e2e/helpers/startExampleServer' +import type { Page } from '@playwright/test' const exampleDir = path.resolve() +/** `makeData(1_000)` at the default page size of 10 gives exactly 100 pages. */ +const TOTAL_ROWS = 1_000 +const PAGE_SIZES = [10, 20, 30, 40, 50] + +let server: Awaited> | undefined + +test.beforeAll(async () => { + // One server for the whole file. A cold Vite start costs more than a test. + test.setTimeout(180_000) + server = await startExampleServer(exampleDir) +}) + +test.afterAll(async () => { + await server?.close() +}) + function collectPageErrors(page: Page) { const errors: Array = [] @@ -22,7 +38,8 @@ function collectPageErrors(page: Page) { } async function openExample(page: Page) { - const server = await startExampleServer(exampleDir) + if (!server) throw new Error('Example server failed to start') + const errors = collectPageErrors(page) await page.route( @@ -36,51 +53,235 @@ async function openExample(page: Page) { await page.goto(server.url) - return { errors, server } + return errors } -async function getFirstBodyRowText(table: Locator) { - const text = await table.locator('tbody tr').first().textContent() +/** `<` is a prefix of `<<`, so every page button match has to be exact. */ +function pageButton(page: Page, label: '<<' | '<' | '>' | '>>') { + return page.getByRole('button', { name: label, exact: true }) +} + +/** The `{pageIndex + 1} of {pageCount}` readout. */ +function pageStatus(page: Page) { + return page.locator('.controls strong') +} + +function goToPageInput(page: Page) { + return page.getByRole('spinbutton') +} + +function pageSizeSelect(page: Page) { + return page.locator('.controls select') +} + +function rowCountLine(page: Page) { + return page.getByText(/Showing .* Rows/) +} + +function bodyRows(page: Page) { + return page.locator('tbody tr') +} + +/** Row data is random faker output, so `table.state` is the stable oracle. */ +async function readPagination(page: Page) { + const text = await page.getByTestId('table-state').textContent() + const state = JSON.parse(text ?? '{}') as { + pagination?: { pageIndex: number; pageSize: number } + } + + return state.pagination +} + +async function expectPagination( + page: Page, + expected: { pageIndex: number; pageSize: number }, +) { + await expect.poll(() => readPagination(page)).toEqual(expected) +} + +async function getFirstBodyRowText(page: Page) { + const text = await bodyRows(page).first().textContent() return text?.replace(/\s+/g, ' ').trim() ?? '' } test('renders the table without crashing', async ({ page }) => { - const { errors, server } = await openExample(page) + const errors = await openExample(page) + const table = page.locator('table').first() - try { - const table = page.locator('table').first() + await expect(table).toBeVisible() + await expect(table.locator('thead th')).toHaveCount(7) + await expect(bodyRows(page)).toHaveCount(10) + await expect(rowCountLine(page)).toHaveText('Showing 10 of 1,000 Rows') + await expectPagination(page, { pageIndex: 0, pageSize: 10 }) - await expect(table).toBeVisible() - await expect(table.locator('thead th').first()).toBeVisible() - await expect(table.locator('tbody tr').first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() - } + expect(errors).toEqual([]) }) test('regenerates table data', async ({ page }) => { - const { errors, server } = await openExample(page) + const errors = await openExample(page) + const regenerateButton = page.getByRole('button', { + name: /^Regenerate Data$/i, + }) - try { - const table = page.locator('table').first() - const bodyRows = table.locator('tbody tr') - const regenerateButton = page.getByRole('button', { - name: /^Regenerate Data$/i, - }) + await expect(bodyRows(page).first()).toBeVisible() - await expect(table).toBeVisible() - await expect(bodyRows.first()).toBeVisible() - await expect(regenerateButton).toBeVisible() + const firstRowBefore = await getFirstBodyRowText(page) - const firstRowBefore = await getFirstBodyRowText(table) + await regenerateButton.click() - await regenerateButton.click() + await expect.poll(() => getFirstBodyRowText(page)).not.toBe(firstRowBefore) + await expect(bodyRows(page)).toHaveCount(10) - await expect.poll(() => getFirstBodyRowText(table)).not.toBe(firstRowBefore) - await expect(bodyRows.first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() - } + expect(errors).toEqual([]) +}) + +test('disables the first and previous buttons on the first page', async ({ + page, +}) => { + const errors = await openExample(page) + + await expect(pageStatus(page)).toHaveText('1 of 100') + await expect(pageButton(page, '<<')).toBeDisabled() + await expect(pageButton(page, '<')).toBeDisabled() + await expect(pageButton(page, '>')).toBeEnabled() + await expect(pageButton(page, '>>')).toBeEnabled() + + expect(errors).toEqual([]) +}) + +test('advances and rewinds one page at a time', async ({ page }) => { + const errors = await openExample(page) + + await expect(pageStatus(page)).toHaveText('1 of 100') + + await pageButton(page, '>').click() + await expect(pageStatus(page)).toHaveText('2 of 100') + await expectPagination(page, { pageIndex: 1, pageSize: 10 }) + // Reaching page two enables the backwards buttons. + await expect(pageButton(page, '<<')).toBeEnabled() + await expect(pageButton(page, '<')).toBeEnabled() + // The controlled input tracks the page rather than going stale. + await expect(goToPageInput(page)).toHaveValue('2') + + const secondPageFirstRow = await getFirstBodyRowText(page) + + await pageButton(page, '>').click() + await expect(pageStatus(page)).toHaveText('3 of 100') + + await pageButton(page, '<').click() + await expect(pageStatus(page)).toHaveText('2 of 100') + // Returning to a page shows the same rows, so paging does not reshuffle. + expect(await getFirstBodyRowText(page)).toBe(secondPageFirstRow) + + // The page window slides but its size and the total do not. + await expect(bodyRows(page)).toHaveCount(10) + await expect(rowCountLine(page)).toHaveText('Showing 10 of 1,000 Rows') + + expect(errors).toEqual([]) +}) + +test('jumps to the last and first pages', async ({ page }) => { + const errors = await openExample(page) + + await pageButton(page, '>>').click() + await expect(pageStatus(page)).toHaveText('100 of 100') + await expectPagination(page, { pageIndex: 99, pageSize: 10 }) + await expect(bodyRows(page)).toHaveCount(10) + await expect(pageButton(page, '>')).toBeDisabled() + await expect(pageButton(page, '>>')).toBeDisabled() + await expect(pageButton(page, '<<')).toBeEnabled() + await expect(pageButton(page, '<')).toBeEnabled() + + await pageButton(page, '<<').click() + await expect(pageStatus(page)).toHaveText('1 of 100') + await expectPagination(page, { pageIndex: 0, pageSize: 10 }) + await expect(pageButton(page, '<<')).toBeDisabled() + await expect(pageButton(page, '<')).toBeDisabled() + + expect(errors).toEqual([]) +}) + +test('goes to an arbitrary page via the Go to page input', async ({ page }) => { + const errors = await openExample(page) + const input = goToPageInput(page) + + await expect(input).toHaveAttribute('min', '1') + await expect(input).toHaveAttribute('max', '100') + await expect(input).toHaveValue('1') + + await input.fill('42') + await expect(pageStatus(page)).toHaveText('42 of 100') + await expectPagination(page, { pageIndex: 41, pageSize: 10 }) + + await input.fill('1') + await expect(pageStatus(page)).toHaveText('1 of 100') + await expect(pageButton(page, '<')).toBeDisabled() + + // Clearing the field falls back to the first page rather than NaN. + await input.fill('7') + await expect(pageStatus(page)).toHaveText('7 of 100') + await input.fill('') + await expectPagination(page, { pageIndex: 0, pageSize: 10 }) + await expect(pageStatus(page)).toHaveText('1 of 100') + + expect(errors).toEqual([]) +}) + +test('recomputes the page index when the page size changes', async ({ + page, +}) => { + const errors = await openExample(page) + const select = pageSizeSelect(page) + + await expect(select.locator('option')).toHaveText( + PAGE_SIZES.map((size) => `Show ${size}`), + ) + await expect(select).toHaveValue('10') + + await select.selectOption('20') + await expect(bodyRows(page)).toHaveCount(20) + await expect(pageStatus(page)).toHaveText('1 of 50') + await expect(rowCountLine(page)).toHaveText('Showing 20 of 1,000 Rows') + await expectPagination(page, { pageIndex: 0, pageSize: 20 }) + + await select.selectOption('50') + await expect(bodyRows(page)).toHaveCount(50) + await expect(pageStatus(page)).toHaveText('1 of 20') + await expect(rowCountLine(page)).toHaveText('Showing 50 of 1,000 Rows') + + // `setPageSize` keeps the row at the top of the page in view by deriving the + // new page index from the old top row index, rather than resetting to page 1. + await select.selectOption('10') + await goToPageInput(page).fill('5') + await expect(pageStatus(page)).toHaveText('5 of 100') + + // Top row index is 4 * 10 = 40, so 20 rows per page lands on page 3. + await select.selectOption('20') + await expect(pageStatus(page)).toHaveText('3 of 50') + await expectPagination(page, { pageIndex: 2, pageSize: 20 }) + + // Top row index is still 2 * 20 = 40, so 50 rows per page lands on page 1. + await select.selectOption('50') + await expect(pageStatus(page)).toHaveText('1 of 20') + await expectPagination(page, { pageIndex: 0, pageSize: 50 }) + + expect(errors).toEqual([]) +}) + +test('resets to the first page when data is regenerated', async ({ page }) => { + const errors = await openExample(page) + + await goToPageInput(page).fill('5') + await expect(pageStatus(page)).toHaveText('5 of 100') + + // `autoResetPageIndex` defaults to true, so new data returns to page one. + await page.getByRole('button', { name: /^Regenerate Data$/i }).click() + + await expect(pageStatus(page)).toHaveText('1 of 100') + await expectPagination(page, { pageIndex: 0, pageSize: 10 }) + await expect(rowCountLine(page)).toHaveText( + `Showing 10 of ${TOTAL_ROWS.toLocaleString('en-US')} Rows`, + ) + + expect(errors).toEqual([]) }) diff --git a/examples/react/row-dnd/src/main.tsx b/examples/react/row-dnd/src/main.tsx index ecb849aecb..810de8a79a 100644 --- a/examples/react/row-dnd/src/main.tsx +++ b/examples/react/row-dnd/src/main.tsx @@ -204,7 +204,9 @@ function App() { -
{JSON.stringify(table.state, null, 2)}
+
+          {JSON.stringify(table.state, null, 2)}
+        
) diff --git a/examples/react/row-pinning/src/main.tsx b/examples/react/row-pinning/src/main.tsx index a57af14f02..505a21e41d 100644 --- a/examples/react/row-pinning/src/main.tsx +++ b/examples/react/row-pinning/src/main.tsx @@ -255,7 +255,7 @@ function App() {
-
{JSON.stringify(table.state, null, 2)}
+
+        {JSON.stringify(table.state, null, 2)}
+      
) } diff --git a/examples/react/row-pinning/tests/e2e/smoke.spec.ts b/examples/react/row-pinning/tests/e2e/smoke.spec.ts index 2eb199bf28..8f0c6ce684 100644 --- a/examples/react/row-pinning/tests/e2e/smoke.spec.ts +++ b/examples/react/row-pinning/tests/e2e/smoke.spec.ts @@ -1,10 +1,29 @@ -import { expect, test } from '@playwright/test' -import type { Locator, Page } from '@playwright/test' import path from 'node:path' +import { expect, test } from '@playwright/test' import { startExampleServer } from '../../../../../tests/e2e/helpers/startExampleServer' +import type { Page } from '@playwright/test' const exampleDir = path.resolve() +/** + * `makeData(1_000, 2, 2)` gives each row two children and each of those two + * children, so pinning one row pins a subtree of seven while the include-leaf + * and include-parent options are on. + */ +const SUBTREE_SIZE = 7 + +let server: Awaited> | undefined + +test.beforeAll(async () => { + // One server for the whole file. A cold Vite start costs more than a test. + test.setTimeout(180_000) + server = await startExampleServer(exampleDir) +}) + +test.afterAll(async () => { + await server?.close() +}) + function collectPageErrors(page: Page) { const errors: Array = [] @@ -22,7 +41,8 @@ function collectPageErrors(page: Page) { } async function openExample(page: Page) { - const server = await startExampleServer(exampleDir) + if (!server) throw new Error('Example server failed to start') + const errors = collectPageErrors(page) await page.route( @@ -36,51 +56,182 @@ async function openExample(page: Page) { await page.goto(server.url) - return { errors, server } + return errors } -async function getFirstBodyRowText(table: Locator) { - const text = await table.locator('tbody tr').first().textContent() - return text?.replace(/\s+/g, ' ').trim() ?? '' +function bodyRows(page: Page) { + return page.locator('tbody tr') } -test('renders the table without crashing', async ({ page }) => { - const { errors, server } = await openExample(page) +/** + * The pin controls sit in each row's first cell. An unpinned row shows ⬆️ then + * ⬇️; a pinned row shows a single ❌. Locating by position avoids matching on + * emoji accessible names, which do not survive name normalisation. + */ +function pinControls(page: Page, rowIndex: number) { + return bodyRows(page).nth(rowIndex).locator('td').first().getByRole('button') +} + +function optionCheckbox(page: Page, label: RegExp) { + return page + .locator('.vertical-options > div') + .filter({ hasText: label }) + .locator('input[type="checkbox"]') +} - try { - const table = page.locator('table').first() +function pageButton(page: Page, label: '<<' | '<' | '>' | '>>') { + return page.getByRole('button', { name: label, exact: true }) +} + +/** Row data is random faker output, so `table.state` is the stable oracle. */ +async function readRowPinning(page: Page) { + const text = await page.getByTestId('table-state').textContent() + const state = JSON.parse(text ?? '{}') as { + rowPinning?: { top?: Array; bottom?: Array } + } - await expect(table).toBeVisible() - await expect(table.locator('thead th').first()).toBeVisible() - await expect(table.locator('tbody tr').first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() + return { + top: state.rowPinning?.top ?? [], + bottom: state.rowPinning?.bottom ?? [], } +} + +async function getRowText(page: Page, index: number) { + const text = await bodyRows(page).nth(index).textContent() + return text?.replace(/\s+/g, ' ').trim() ?? '' +} + +test('renders the table without crashing', async ({ page }) => { + const errors = await openExample(page) + const table = page.locator('table').first() + + await expect(table).toBeVisible() + await expect(bodyRows(page).first()).toBeVisible() + expect(await readRowPinning(page)).toEqual({ top: [], bottom: [] }) + // An unpinned row offers both pin directions and no unpin control. + await expect(pinControls(page, 0)).toHaveCount(2) + await expect(pinControls(page, 0).nth(0)).toHaveText('⬆️') + await expect(pinControls(page, 0).nth(1)).toHaveText('⬇️') + + expect(errors).toEqual([]) }) test('regenerates table data', async ({ page }) => { - const { errors, server } = await openExample(page) + const errors = await openExample(page) + const regenerateButton = page.getByRole('button', { + name: /^Regenerate Data$/i, + }) - try { - const table = page.locator('table').first() - const bodyRows = table.locator('tbody tr') - const regenerateButton = page.getByRole('button', { - name: /^Regenerate Data$/i, - }) + await expect(bodyRows(page).first()).toBeVisible() - await expect(table).toBeVisible() - await expect(bodyRows.first()).toBeVisible() - await expect(regenerateButton).toBeVisible() + const firstRowBefore = await getRowText(page, 0) - const firstRowBefore = await getFirstBodyRowText(table) + await regenerateButton.click() - await regenerateButton.click() + await expect.poll(() => getRowText(page, 0)).not.toBe(firstRowBefore) - await expect.poll(() => getFirstBodyRowText(table)).not.toBe(firstRowBefore) - await expect(bodyRows.first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() - } + expect(errors).toEqual([]) +}) + +test('pins a row to the top', async ({ page }) => { + const errors = await openExample(page) + + await pinControls(page, 2).nth(0).click() + + // Include-leaf and include-parent are on by default, so the row arrives with + // its two children and their two children apiece. + await expect + .poll(async () => (await readRowPinning(page)).top) + .toHaveLength(SUBTREE_SIZE) + expect((await readRowPinning(page)).top[0]).toBe('2') + expect((await readRowPinning(page)).bottom).toEqual([]) + + // The pinned row is lifted to the front and swaps its controls for an unpin. + await expect(pinControls(page, 0)).toHaveCount(1) + await expect(pinControls(page, 0)).toHaveText('❌') + + expect(errors).toEqual([]) +}) + +test('pins a row to the bottom', async ({ page }) => { + const errors = await openExample(page) + + await pinControls(page, 1).nth(1).click() + + await expect + .poll(async () => (await readRowPinning(page)).bottom) + .toHaveLength(SUBTREE_SIZE) + expect((await readRowPinning(page)).top).toEqual([]) + + // The pinned subtree now sits at the very end of the table body. + const lastIndex = (await bodyRows(page).count()) - 1 + await expect(pinControls(page, lastIndex)).toHaveText('❌') + await expect(pinControls(page, 0)).toHaveCount(2) + + expect(errors).toEqual([]) +}) + +test('unpins a row', async ({ page }) => { + const errors = await openExample(page) + + await pinControls(page, 2).nth(0).click() + await expect + .poll(async () => (await readRowPinning(page)).top) + .toHaveLength(SUBTREE_SIZE) + + await pinControls(page, 0).click() + + // Unpinning the parent releases the descendants that were pinned with it. + await expect.poll(async () => (await readRowPinning(page)).top).toEqual([]) + await expect(pinControls(page, 0)).toHaveCount(2) + await expect(pinControls(page, 0).nth(0)).toHaveText('⬆️') + await expect(pinControls(page, 0).nth(1)).toHaveText('⬇️') + + expect(errors).toEqual([]) +}) + +test('keeps pinned rows visible across pages', async ({ page }) => { + const errors = await openExample(page) + + await pinControls(page, 2).nth(0).click() + await expect + .poll(async () => (await readRowPinning(page)).top) + .toHaveLength(SUBTREE_SIZE) + + const pinnedText = await getRowText(page, 0) + + // `keepPinnedRows` is on by default, so paging away keeps the row on screen. + await pageButton(page, '>').click() + + await expect.poll(() => getRowText(page, 0)).toBe(pinnedText) + await expect(pinControls(page, 0)).toHaveText('❌') + await expect + .poll(async () => (await readRowPinning(page)).top) + .toHaveLength(SUBTREE_SIZE) + + expect(errors).toEqual([]) +}) + +test('duplicates a pinned row when copying is enabled', async ({ page }) => { + const errors = await openExample(page) + const copyOption = optionCheckbox(page, /Duplicate\/Keep Pinned Rows/) + + await expect(copyOption).not.toBeChecked() + + await pinControls(page, 2).nth(0).click() + await expect + .poll(async () => (await readRowPinning(page)).top) + .toHaveLength(SUBTREE_SIZE) + + const rowsPinnedOnly = await bodyRows(page).count() + + await copyOption.check() + + // With copying on the row keeps its place in the body as well as its pinned + // slot, so the body grows rather than simply reordering. + await expect + .poll(() => bodyRows(page).count()) + .toBeGreaterThan(rowsPinnedOnly) + + expect(errors).toEqual([]) }) diff --git a/examples/react/row-selection/src/index.css b/examples/react/row-selection/src/index.css index 0a099f6d9f..1584912764 100644 --- a/examples/react/row-selection/src/index.css +++ b/examples/react/row-selection/src/index.css @@ -226,7 +226,8 @@ button[disabled] { } .sortable-header, -.sortable { +.sortable, +.selection-checkbox { cursor: pointer; user-select: none; } diff --git a/examples/react/row-selection/src/main.tsx b/examples/react/row-selection/src/main.tsx index f2e1c1f198..fcfe066e95 100644 --- a/examples/react/row-selection/src/main.tsx +++ b/examples/react/row-selection/src/main.tsx @@ -216,7 +216,7 @@ function App() {
-
-            {data.length < 1_001 && JSON.stringify(table.state, null, 2)}
+          
+            {JSON.stringify(table.state, null, 2)}
           
@@ -397,13 +397,15 @@ function IndeterminateCheckbox({ if (typeof indeterminate === 'boolean') { ref.current.indeterminate = !rest.checked && indeterminate } - }, [ref, indeterminate]) + // `checked` belongs here too: `getIsSomePageRowsSelected` stays true when + // every page row is selected, so deselecting one only changes `checked`. + }, [ref, indeterminate, rest.checked]) return ( ) diff --git a/examples/react/row-selection/tests/e2e/smoke.spec.ts b/examples/react/row-selection/tests/e2e/smoke.spec.ts index 2eb199bf28..dbce69e714 100644 --- a/examples/react/row-selection/tests/e2e/smoke.spec.ts +++ b/examples/react/row-selection/tests/e2e/smoke.spec.ts @@ -1,10 +1,26 @@ -import { expect, test } from '@playwright/test' -import type { Locator, Page } from '@playwright/test' import path from 'node:path' +import { expect, test } from '@playwright/test' import { startExampleServer } from '../../../../../tests/e2e/helpers/startExampleServer' +import type { Locator, Page } from '@playwright/test' const exampleDir = path.resolve() +/** `makeData(1_000)` with a uuid `getRowId`, ten rows to a page. */ +const TOTAL_ROWS = '1,000' +const PAGE_SIZE = 10 + +let server: Awaited> | undefined + +test.beforeAll(async () => { + // One server for the whole file. A cold Vite start costs more than a test. + test.setTimeout(180_000) + server = await startExampleServer(exampleDir) +}) + +test.afterAll(async () => { + await server?.close() +}) + function collectPageErrors(page: Page) { const errors: Array = [] @@ -22,7 +38,8 @@ function collectPageErrors(page: Page) { } async function openExample(page: Page) { - const server = await startExampleServer(exampleDir) + if (!server) throw new Error('Example server failed to start') + const errors = collectPageErrors(page) await page.route( @@ -36,51 +53,258 @@ async function openExample(page: Page) { await page.goto(server.url) - return { errors, server } + return errors +} + +function rowCheckboxes(page: Page) { + return page.locator('tbody tr td:first-child input[type="checkbox"]') +} + +/** Bound to `getIsAllRowsSelected`, so it spans every page. */ +function headerCheckbox(page: Page) { + return page.locator('thead th').first().locator('input[type="checkbox"]') +} + +/** Bound to `getIsAllPageRowsSelected`, so it spans the current page only. */ +function footerCheckbox(page: Page) { + return page.locator('tfoot input[type="checkbox"]') +} + +/** Anchored so the regex cannot also match an ancestor with extra text. */ +function selectionSummary(page: Page) { + return page + .locator('div') + .filter({ hasText: /^[\d,]+ of [\d,]+ Total Rows Selected$/ }) +} + +function pageRowsLabel(page: Page) { + return page.locator('tfoot td').nth(1) +} + +function globalFilterInput(page: Page) { + return page.getByPlaceholder('Search all columns...') +} + +function pageButton(page: Page, label: '<<' | '<' | '>' | '>>') { + return page.getByRole('button', { name: label, exact: true }) +} + +function isIndeterminate(checkbox: Locator) { + return checkbox.evaluate((el) => (el as HTMLInputElement).indeterminate) +} + +async function expectIndeterminate(checkbox: Locator, expected: boolean) { + await expect.poll(() => isIndeterminate(checkbox)).toBe(expected) +} + +/** Row ids are uuids, so only the number of selected keys is assertable. */ +async function readSelectionCount(page: Page) { + const text = await page.getByTestId('table-state').textContent() + const state = JSON.parse(text ?? '{}') as { + rowSelection?: Record + } + + return Object.keys(state.rowSelection ?? {}).length } -async function getFirstBodyRowText(table: Locator) { - const text = await table.locator('tbody tr').first().textContent() +async function expectSelectionCount(page: Page, expected: number) { + await expect.poll(() => readSelectionCount(page)).toBe(expected) + await expect(selectionSummary(page)).toHaveText( + `${expected.toLocaleString('en-US')} of ${TOTAL_ROWS} Total Rows Selected`, + ) +} + +async function readGlobalFilter(page: Page) { + const text = await page.getByTestId('table-state').textContent() + const state = JSON.parse(text ?? '{}') as { globalFilter?: string } + + return state.globalFilter ?? '' +} + +async function getFirstBodyRowText(page: Page) { + const text = await page.locator('tbody tr').first().textContent() return text?.replace(/\s+/g, ' ').trim() ?? '' } test('renders the table without crashing', async ({ page }) => { - const { errors, server } = await openExample(page) + const errors = await openExample(page) + const table = page.locator('table').first() - try { - const table = page.locator('table').first() + await expect(table).toBeVisible() + await expect(page.locator('tbody tr')).toHaveCount(PAGE_SIZE) + await expect(pageRowsLabel(page)).toHaveText(`Page Rows (${PAGE_SIZE})`) + await expectSelectionCount(page, 0) + await expect(headerCheckbox(page)).not.toBeChecked() + await expectIndeterminate(headerCheckbox(page), false) - await expect(table).toBeVisible() - await expect(table.locator('thead th').first()).toBeVisible() - await expect(table.locator('tbody tr').first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() - } + expect(errors).toEqual([]) }) test('regenerates table data', async ({ page }) => { - const { errors, server } = await openExample(page) + const errors = await openExample(page) + const regenerateButton = page.getByRole('button', { + name: /^Regenerate Data$/i, + }) + + await expect(page.locator('tbody tr').first()).toBeVisible() + + const firstRowBefore = await getFirstBodyRowText(page) + + await regenerateButton.click() + + await expect.poll(() => getFirstBodyRowText(page)).not.toBe(firstRowBefore) + await expect(page.locator('tbody tr')).toHaveCount(PAGE_SIZE) - try { - const table = page.locator('table').first() - const bodyRows = table.locator('tbody tr') - const regenerateButton = page.getByRole('button', { - name: /^Regenerate Data$/i, - }) + expect(errors).toEqual([]) +}) + +test('selects and deselects a single row', async ({ page }) => { + const errors = await openExample(page) + const firstRow = rowCheckboxes(page).first() + + await firstRow.check() + + await expect(firstRow).toBeChecked() + await expectSelectionCount(page, 1) + // One of many rows selected leaves both select-all boxes partially filled. + await expectIndeterminate(headerCheckbox(page), true) + await expectIndeterminate(footerCheckbox(page), true) + await expect(headerCheckbox(page)).not.toBeChecked() + + await firstRow.uncheck() + + await expect(firstRow).not.toBeChecked() + await expectSelectionCount(page, 0) + await expectIndeterminate(headerCheckbox(page), false) + await expectIndeterminate(footerCheckbox(page), false) + + expect(errors).toEqual([]) +}) + +test('selects an inclusive range with Shift-click', async ({ page }) => { + const errors = await openExample(page) + const checkboxes = rowCheckboxes(page) + + await checkboxes.nth(1).check() + await expectSelectionCount(page, 1) + + // The range covers both endpoints, so rows 1 through 5 is five rows. + await checkboxes.nth(5).click({ modifiers: ['Shift'] }) + + await expectSelectionCount(page, 5) + + for (const index of [1, 2, 3, 4, 5]) { + await expect(checkboxes.nth(index)).toBeChecked() + } + + // Rows outside the range are untouched. + await expect(checkboxes.nth(0)).not.toBeChecked() + await expect(checkboxes.nth(6)).not.toBeChecked() + + expect(errors).toEqual([]) +}) + +test('selects every row on the current page from the footer', async ({ + page, +}) => { + const errors = await openExample(page) + + await footerCheckbox(page).check() + + await expectSelectionCount(page, PAGE_SIZE) + await expect(footerCheckbox(page)).toBeChecked() + await expectIndeterminate(footerCheckbox(page), false) + // Ten of a thousand rows is still only a partial selection overall. + await expect(headerCheckbox(page)).not.toBeChecked() + await expectIndeterminate(headerCheckbox(page), true) + + for (let index = 0; index < PAGE_SIZE; index++) { + await expect(rowCheckboxes(page).nth(index)).toBeChecked() + } + + // Clearing a single row drops the footer back to a partial state. + await rowCheckboxes(page).nth(3).uncheck() + await expectSelectionCount(page, PAGE_SIZE - 1) + await expect(footerCheckbox(page)).not.toBeChecked() + await expectIndeterminate(footerCheckbox(page), true) - await expect(table).toBeVisible() - await expect(bodyRows.first()).toBeVisible() - await expect(regenerateButton).toBeVisible() + await footerCheckbox(page).check() + await expectSelectionCount(page, PAGE_SIZE) - const firstRowBefore = await getFirstBodyRowText(table) + expect(errors).toEqual([]) +}) + +test('selects every row across all pages from the header', async ({ page }) => { + const errors = await openExample(page) + + await headerCheckbox(page).check() - await regenerateButton.click() + await expectSelectionCount(page, 1_000) + await expect(headerCheckbox(page)).toBeChecked() + await expectIndeterminate(headerCheckbox(page), false) + await expect(footerCheckbox(page)).toBeChecked() - await expect.poll(() => getFirstBodyRowText(table)).not.toBe(firstRowBefore) - await expect(bodyRows.first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() + // Selection is keyed by row id rather than position, so the next page's rows + // come back already selected. + await pageButton(page, '>').click() + + await expect(footerCheckbox(page)).toBeChecked() + await expectSelectionCount(page, 1_000) + + for (let index = 0; index < PAGE_SIZE; index++) { + await expect(rowCheckboxes(page).nth(index)).toBeChecked() } + + await headerCheckbox(page).uncheck() + await expectSelectionCount(page, 0) + + expect(errors).toEqual([]) +}) + +test('keeps selection when the global filter narrows the rows', async ({ + page, +}) => { + const errors = await openExample(page) + + await rowCheckboxes(page).first().check() + await expectSelectionCount(page, 1) + + // The first name of the row that was just selected, so it survives the + // filter. The input debounces, so poll the state rather than sleeping. + const firstName = + ( + await page.locator('tbody tr').first().locator('td').nth(1).textContent() + )?.trim() ?? '' + + await globalFilterInput(page).fill(firstName) + + await expect.poll(() => readGlobalFilter(page)).toBe(firstName) + await expect(page.locator('tbody tr').first()).toBeVisible() + // The summary counts pre-filtered rows, so the denominator does not move. + await expectSelectionCount(page, 1) + + await globalFilterInput(page).fill('') + + await expect.poll(() => readGlobalFilter(page)).toBe('') + await expectSelectionCount(page, 1) + + expect(errors).toEqual([]) +}) + +test('tracks the page size in the footer row count', async ({ page }) => { + const errors = await openExample(page) + const select = page.locator('.controls select') + + await expect(pageRowsLabel(page)).toHaveText('Page Rows (10)') + + await select.selectOption('20') + + await expect(page.locator('tbody tr')).toHaveCount(20) + await expect(pageRowsLabel(page)).toHaveText('Page Rows (20)') + + // Selecting the page now covers twenty rows rather than ten. + await footerCheckbox(page).check() + await expectSelectionCount(page, 20) + + expect(errors).toEqual([]) }) diff --git a/examples/react/sorting/src/main.tsx b/examples/react/sorting/src/main.tsx index 8b2e7915af..ae81a1a024 100644 --- a/examples/react/sorting/src/main.tsx +++ b/examples/react/sorting/src/main.tsx @@ -183,7 +183,9 @@ function App() {
{table.getRowModel().rows.length.toLocaleString()} Rows
{/* Store mode: dump full table state for debugging */} -
{JSON.stringify(table.state, null, 2)}
+
+        {JSON.stringify(table.state, null, 2)}
+      
) } diff --git a/examples/react/sorting/tests/e2e/smoke.spec.ts b/examples/react/sorting/tests/e2e/smoke.spec.ts index 2eb199bf28..a840f850e3 100644 --- a/examples/react/sorting/tests/e2e/smoke.spec.ts +++ b/examples/react/sorting/tests/e2e/smoke.spec.ts @@ -1,10 +1,41 @@ -import { expect, test } from '@playwright/test' -import type { Locator, Page } from '@playwright/test' import path from 'node:path' +import { expect, test } from '@playwright/test' import { startExampleServer } from '../../../../../tests/e2e/helpers/startExampleServer' +import type { Page } from '@playwright/test' const exampleDir = path.resolve() +/** Body cell index per column, matching the column order in `src/main.tsx`. */ +const COLUMN = { + rowNumber: 0, + firstName: 1, + lastName: 2, + email: 3, + age: 4, + visits: 5, + status: 6, + progress: 7, + rank: 8, + createdAt: 9, +} as const + +type ColumnName = keyof typeof COLUMN + +/** `sortStatusFn` in `src/main.tsx` orders statuses this way, not alphabetically. */ +const STATUS_ORDER = ['single', 'complicated', 'relationship'] + +let server: Awaited> | undefined + +test.beforeAll(async () => { + // One server for the whole file. A cold Vite start costs more than a test. + test.setTimeout(180_000) + server = await startExampleServer(exampleDir) +}) + +test.afterAll(async () => { + await server?.close() +}) + function collectPageErrors(page: Page) { const errors: Array = [] @@ -22,7 +53,8 @@ function collectPageErrors(page: Page) { } async function openExample(page: Page) { - const server = await startExampleServer(exampleDir) + if (!server) throw new Error('Example server failed to start') + const errors = collectPageErrors(page) await page.route( @@ -36,51 +68,281 @@ async function openExample(page: Page) { await page.goto(server.url) - return { errors, server } + return errors +} + +/** The clickable header, which also carries the title and the sort glyph. */ +function sortToggle(page: Page, column: ColumnName) { + return page + .locator('thead th') + .nth(COLUMN[column]) + .locator('.sortable-header') +} + +/** Visible body cell text for one column, in render order. */ +async function readColumn(page: Page, column: ColumnName) { + const cells = await page + .locator(`tbody tr td:nth-child(${COLUMN[column] + 1})`) + .allTextContents() + + return cells.map((cell) => cell.trim()) } -async function getFirstBodyRowText(table: Locator) { - const text = await table.locator('tbody tr').first().textContent() +async function readNumericColumn(page: Page, column: ColumnName) { + return (await readColumn(page, column)).map(Number) +} + +/** Row data is random faker output, so `table.state` is the stable oracle. */ +async function readSorting(page: Page) { + const text = await page.getByTestId('table-state').textContent() + const state = JSON.parse(text ?? '{}') as { + sorting?: Array<{ id: string; desc: boolean }> + } + + return state.sorting ?? [] +} + +async function expectSorting( + page: Page, + expected: Array<{ id: string; desc: boolean }>, +) { + await expect.poll(() => readSorting(page)).toEqual(expected) +} + +async function getFirstBodyRowText(page: Page) { + const text = await page.locator('tbody tr').first().textContent() return text?.replace(/\s+/g, ' ').trim() ?? '' } test('renders the table without crashing', async ({ page }) => { - const { errors, server } = await openExample(page) + const errors = await openExample(page) + const table = page.locator('table').first() - try { - const table = page.locator('table').first() + await expect(table).toBeVisible() + await expect(table.locator('thead th')).toHaveCount(10) + // The example renders 1,000 rows but only slices the first ten into the body. + await expect(table.locator('tbody tr')).toHaveCount(10) + await expect(page.getByText('1,000 Rows')).toBeVisible() + await expectSorting(page, []) - await expect(table).toBeVisible() - await expect(table.locator('thead th').first()).toBeVisible() - await expect(table.locator('tbody tr').first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() - } + expect(errors).toEqual([]) }) test('regenerates table data', async ({ page }) => { - const { errors, server } = await openExample(page) + const errors = await openExample(page) + const regenerateButton = page.getByRole('button', { + name: /^Regenerate Data$/i, + }) - try { - const table = page.locator('table').first() - const bodyRows = table.locator('tbody tr') - const regenerateButton = page.getByRole('button', { - name: /^Regenerate Data$/i, - }) + await expect(page.locator('tbody tr').first()).toBeVisible() - await expect(table).toBeVisible() - await expect(bodyRows.first()).toBeVisible() - await expect(regenerateButton).toBeVisible() + const firstRowBefore = await getFirstBodyRowText(page) - const firstRowBefore = await getFirstBodyRowText(table) + await regenerateButton.click() - await regenerateButton.click() + await expect.poll(() => getFirstBodyRowText(page)).not.toBe(firstRowBefore) + await expect(page.locator('tbody tr')).toHaveCount(10) - await expect.poll(() => getFirstBodyRowText(table)).not.toBe(firstRowBefore) - await expect(bodyRows.first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() - } + expect(errors).toEqual([]) +}) + +test('infers the first sort direction from the column value type', async ({ + page, +}) => { + const errors = await openExample(page) + + // String columns start ascending, every other value type starts descending. + await expect(sortToggle(page, 'firstName')).toHaveAttribute( + 'title', + 'Sort ascending', + ) + await expect(sortToggle(page, 'status')).toHaveAttribute( + 'title', + 'Sort ascending', + ) + await expect(sortToggle(page, 'age')).toHaveAttribute( + 'title', + 'Sort descending', + ) + await expect(sortToggle(page, 'createdAt')).toHaveAttribute( + 'title', + 'Sort descending', + ) + + // `sortDescFirst: false` overrides the inference on a nullable string column, + // where the first row's value may be undefined and defeat auto detection. + await expect(sortToggle(page, 'lastName')).toHaveAttribute( + 'title', + 'Sort ascending', + ) + + // The row number column has no accessor, so it cannot sort. + await expect(sortToggle(page, 'rowNumber')).toHaveCount(0) + + expect(errors).toEqual([]) +}) + +test('cycles a string column ascending, descending, then clears', async ({ + page, +}) => { + const errors = await openExample(page) + const toggle = sortToggle(page, 'firstName') + + await toggle.click() + await expectSorting(page, [{ id: 'firstName', desc: false }]) + await expect(toggle).toContainText('🔼') + await expect(toggle).toHaveAttribute('title', 'Sort descending') + + await toggle.click() + await expectSorting(page, [{ id: 'firstName', desc: true }]) + await expect(toggle).toContainText('🔽') + await expect(toggle).toHaveAttribute('title', 'Clear sort') + + await toggle.click() + await expectSorting(page, []) + await expect(toggle).not.toContainText('🔼') + await expect(toggle).not.toContainText('🔽') + await expect(toggle).toHaveAttribute('title', 'Sort ascending') + + expect(errors).toEqual([]) +}) + +test('starts a numeric column descending', async ({ page }) => { + const errors = await openExample(page) + const toggle = sortToggle(page, 'age') + + await toggle.click() + await expectSorting(page, [{ id: 'age', desc: true }]) + await expect(toggle).toContainText('🔽') + + await toggle.click() + await expectSorting(page, [{ id: 'age', desc: false }]) + await expect(toggle).toContainText('🔼') + + await toggle.click() + await expectSorting(page, []) + + expect(errors).toEqual([]) +}) + +test('reorders rows to match the sorted column', async ({ page }) => { + const errors = await openExample(page) + const toggle = sortToggle(page, 'age') + const unsorted = await readNumericColumn(page, 'age') + + await toggle.click() + await expect(toggle).toContainText('🔽') + + const descending = await readNumericColumn(page, 'age') + expect(descending).toEqual([...descending].sort((a, b) => b - a)) + // The top row now holds the maximum across all 1,000 rows, which is at least + // the maximum of the ten that happened to be visible before sorting. + expect(descending[0]).toBeGreaterThanOrEqual(Math.max(...unsorted)) + + await toggle.click() + await expect(toggle).toContainText('🔼') + + const ascending = await readNumericColumn(page, 'age') + expect(ascending).toEqual([...ascending].sort((a, b) => a - b)) + expect(ascending[0]).toBeLessThanOrEqual(Math.min(...unsorted)) + + expect(errors).toEqual([]) +}) + +test('inverts the rendered order on the Rank column', async ({ page }) => { + const errors = await openExample(page) + const toggle = sortToggle(page, 'rank') + const unsorted = await readNumericColumn(page, 'rank') + + await toggle.click() + await expectSorting(page, [{ id: 'rank', desc: true }]) + await expect(toggle).toContainText('🔽') + + // `invertSorting: true` flips the comparison after the descending flip, so a + // column marked descending renders ascending values. Golf scores, not typos. + const ranks = await readNumericColumn(page, 'rank') + expect(ranks).toEqual([...ranks].sort((a, b) => a - b)) + expect(ranks[0]).toBeLessThanOrEqual(Math.min(...unsorted)) + + expect(errors).toEqual([]) +}) + +test('keeps undefined values last in both sort directions', async ({ + page, +}) => { + const errors = await openExample(page) + const toggle = sortToggle(page, 'lastName') + + // Roughly a tenth of the 1,000 rows have an undefined last name. The default + // `sortUndefined` would surface them at one end; `'last'` pins them to the + // bottom regardless of direction, so neither direction shows a blank cell. + await toggle.click() + await expect(toggle).toContainText('🔼') + + const ascending = await readColumn(page, 'lastName') + expect(ascending).toHaveLength(10) + expect(ascending.filter((name) => name === '')).toEqual([]) + + await toggle.click() + await expect(toggle).toContainText('🔽') + + const descending = await readColumn(page, 'lastName') + expect(descending).toHaveLength(10) + expect(descending.filter((name) => name === '')).toEqual([]) + + expect(errors).toEqual([]) +}) + +test('sorts by a custom sort function', async ({ page }) => { + const errors = await openExample(page) + const toggle = sortToggle(page, 'status') + + // Ascending puts `single` first. An alphanumeric fallback would put + // `complicated` there instead, so this fails if `sortStatusFn` is dropped. + await toggle.click() + await expectSorting(page, [{ id: 'status', desc: false }]) + await expect(toggle).toContainText('🔼') + + const ascending = await readColumn(page, 'status') + expect([...new Set(ascending)]).toEqual([STATUS_ORDER[0]]) + + await toggle.click() + await expect(toggle).toContainText('🔽') + + const descending = await readColumn(page, 'status') + expect([...new Set(descending)]).toEqual([STATUS_ORDER[2]]) + + expect(errors).toEqual([]) +}) + +test('multi-sorts with Shift-click', async ({ page }) => { + const errors = await openExample(page) + + await sortToggle(page, 'status').click() + await expectSorting(page, [{ id: 'status', desc: false }]) + + // Shift appends a column rather than replacing the sort. + await sortToggle(page, 'age').click({ modifiers: ['Shift'] }) + await expectSorting(page, [ + { id: 'status', desc: false }, + { id: 'age', desc: true }, + ]) + await expect(sortToggle(page, 'status')).toContainText('🔼') + await expect(sortToggle(page, 'age')).toContainText('🔽') + + // A shifted column runs its own asc/desc/remove cycle. + await sortToggle(page, 'age').click({ modifiers: ['Shift'] }) + await expectSorting(page, [ + { id: 'status', desc: false }, + { id: 'age', desc: false }, + ]) + + await sortToggle(page, 'age').click({ modifiers: ['Shift'] }) + await expectSorting(page, [{ id: 'status', desc: false }]) + + // An unshifted click on another column replaces the whole sort. + await sortToggle(page, 'firstName').click() + await expectSorting(page, [{ id: 'firstName', desc: false }]) + + expect(errors).toEqual([]) }) diff --git a/examples/react/sub-components/tests/e2e/smoke.spec.ts b/examples/react/sub-components/tests/e2e/smoke.spec.ts index 2eb199bf28..fb32154dbd 100644 --- a/examples/react/sub-components/tests/e2e/smoke.spec.ts +++ b/examples/react/sub-components/tests/e2e/smoke.spec.ts @@ -1,10 +1,24 @@ -import { expect, test } from '@playwright/test' -import type { Locator, Page } from '@playwright/test' import path from 'node:path' +import { expect, test } from '@playwright/test' import { startExampleServer } from '../../../../../tests/e2e/helpers/startExampleServer' +import type { Page } from '@playwright/test' const exampleDir = path.resolve() +const TOTAL_ROWS = 10 + +let server: Awaited> | undefined + +test.beforeAll(async () => { + // One server for the whole file. A cold Vite start costs more than a test. + test.setTimeout(180_000) + server = await startExampleServer(exampleDir) +}) + +test.afterAll(async () => { + await server?.close() +}) + function collectPageErrors(page: Page) { const errors: Array = [] @@ -22,7 +36,8 @@ function collectPageErrors(page: Page) { } async function openExample(page: Page) { - const server = await startExampleServer(exampleDir) + if (!server) throw new Error('Example server failed to start') + const errors = collectPageErrors(page) await page.route( @@ -36,51 +51,127 @@ async function openExample(page: Page) { await page.goto(server.url) - return { errors, server } + return errors } -async function getFirstBodyRowText(table: Locator) { - const text = await table.locator('tbody tr').first().textContent() +function bodyRows(page: Page) { + return page.locator('tbody tr') +} + +/** Every data row carries an expander in its first cell. */ +function rowExpander(page: Page, index: number) { + return bodyRows(page).nth(index).getByRole('button') +} + +/** The sub component renders the row's own data as JSON inside a `
`. */
+function subComponents(page: Page) {
+  return page.locator('tbody pre')
+}
+
+async function readSubComponentJson(page: Page, index: number) {
+  const text = await subComponents(page).nth(index).textContent()
+  return JSON.parse(text ?? '{}') as Record
+}
+
+async function getFirstBodyRowText(page: Page) {
+  const text = await bodyRows(page).first().textContent()
   return text?.replace(/\s+/g, ' ').trim() ?? ''
 }
 
 test('renders the table without crashing', async ({ page }) => {
-  const { errors, server } = await openExample(page)
-
-  try {
-    const table = page.locator('table').first()
-
-    await expect(table).toBeVisible()
-    await expect(table.locator('thead th').first()).toBeVisible()
-    await expect(table.locator('tbody tr').first()).toBeVisible()
-    expect(errors).toEqual([])
-  } finally {
-    await server.close()
-  }
+  const errors = await openExample(page)
+  const table = page.locator('table').first()
+
+  await expect(table).toBeVisible()
+  await expect(bodyRows(page)).toHaveCount(TOTAL_ROWS)
+  // Nothing is expanded, so no sub component rows exist yet.
+  await expect(subComponents(page)).toHaveCount(0)
+  await expect(rowExpander(page, 0)).toHaveText('👉')
+
+  expect(errors).toEqual([])
 })
 
 test('regenerates table data', async ({ page }) => {
-  const { errors, server } = await openExample(page)
+  const errors = await openExample(page)
+  const regenerateButton = page.getByRole('button', {
+    name: /^Regenerate Data$/i,
+  })
+
+  await expect(bodyRows(page).first()).toBeVisible()
+
+  const firstRowBefore = await getFirstBodyRowText(page)
+
+  await regenerateButton.click()
+
+  await expect.poll(() => getFirstBodyRowText(page)).not.toBe(firstRowBefore)
+  await expect(bodyRows(page)).toHaveCount(TOTAL_ROWS)
+
+  expect(errors).toEqual([])
+})
+
+test('expands a row to reveal its sub component', async ({ page }) => {
+  const errors = await openExample(page)
+
+  await rowExpander(page, 0).click()
+
+  await expect(rowExpander(page, 0)).toHaveText('👇')
+  await expect(subComponents(page)).toHaveCount(1)
+  // The sub component adds a full width row directly beneath its parent.
+  await expect(bodyRows(page)).toHaveCount(TOTAL_ROWS + 1)
+  await expect(bodyRows(page).nth(1).locator('td')).toHaveCount(1)
+
+  await rowExpander(page, 0).click()
+
+  await expect(rowExpander(page, 0)).toHaveText('👉')
+  await expect(subComponents(page)).toHaveCount(0)
+  await expect(bodyRows(page)).toHaveCount(TOTAL_ROWS)
+
+  expect(errors).toEqual([])
+})
+
+test('renders the expanded row own data in the sub component', async ({
+  page,
+}) => {
+  const errors = await openExample(page)
+
+  // Read a value straight out of the row before opening it, so the sub
+  // component can be checked against its own parent rather than any row.
+  const ageCell = await bodyRows(page)
+    .first()
+    .locator('td')
+    .nth(3)
+    .textContent()
+
+  await rowExpander(page, 0).click()
+  await expect(subComponents(page)).toHaveCount(1)
+
+  const original = await readSubComponentJson(page, 0)
+  expect(original['age']).toBe(Number(ageCell?.trim()))
+  expect(original).toHaveProperty('firstName')
+  expect(original).toHaveProperty('status')
+
+  expect(errors).toEqual([])
+})
+
+test('keeps several sub components open at once', async ({ page }) => {
+  const errors = await openExample(page)
+
+  await rowExpander(page, 0).click()
+  await expect(subComponents(page)).toHaveCount(1)
 
-  try {
-    const table = page.locator('table').first()
-    const bodyRows = table.locator('tbody tr')
-    const regenerateButton = page.getByRole('button', {
-      name: /^Regenerate Data$/i,
-    })
+  // Row 1 is now the first sub component row, so the next data row is row 2.
+  await rowExpander(page, 2).click()
 
-    await expect(table).toBeVisible()
-    await expect(bodyRows.first()).toBeVisible()
-    await expect(regenerateButton).toBeVisible()
+  await expect(subComponents(page)).toHaveCount(2)
+  await expect(bodyRows(page)).toHaveCount(TOTAL_ROWS + 2)
+  await expect(rowExpander(page, 0)).toHaveText('👇')
+  await expect(rowExpander(page, 2)).toHaveText('👇')
 
-    const firstRowBefore = await getFirstBodyRowText(table)
+  // Collapsing one leaves the other open.
+  await rowExpander(page, 0).click()
 
-    await regenerateButton.click()
+  await expect(subComponents(page)).toHaveCount(1)
+  await expect(bodyRows(page)).toHaveCount(TOTAL_ROWS + 1)
 
-    await expect.poll(() => getFirstBodyRowText(table)).not.toBe(firstRowBefore)
-    await expect(bodyRows.first()).toBeVisible()
-    expect(errors).toEqual([])
-  } finally {
-    await server.close()
-  }
+  expect(errors).toEqual([])
 })
diff --git a/examples/react/with-tanstack-query/src/main.tsx b/examples/react/with-tanstack-query/src/main.tsx
index 54cbd78c4d..ec8dfe0ca2 100644
--- a/examples/react/with-tanstack-query/src/main.tsx
+++ b/examples/react/with-tanstack-query/src/main.tsx
@@ -181,7 +181,9 @@ function App() {
         {dataQuery.data?.rowCount.toLocaleString()} Rows
       
       
-
{JSON.stringify(table.state, null, 2)}
+
+        {JSON.stringify(table.state, null, 2)}
+      
) } diff --git a/packages/lit-table/src/TableController.ts b/packages/lit-table/src/TableController.ts index 458d156e17..481e72b166 100644 --- a/packages/lit-table/src/TableController.ts +++ b/packages/lit-table/src/TableController.ts @@ -1,4 +1,9 @@ import { constructTable } from '@tanstack/table-core' +import { createRenderPhaseSource } from '@tanstack/table-core/reactivity' +import { + table_publishExternalState, + table_setOptions, +} from '@tanstack/table-core/static-functions' import { shallow } from '@tanstack/lit-store' import { litReactivity } from './reactivity' import { FlexRender } from './flexRender' @@ -122,9 +127,16 @@ export class TableController< host: ReactiveControllerHost private _table: Table | null = null + private _rootSource?: { + get: () => TableState + markCommitted: (snapshot: TableState) => void + subscribe: (listener: (value: TableState) => void) => { + unsubscribe: () => void + } + } private _storeSubscription?: { unsubscribe: () => void } - private _optionsSubscription?: { unsubscribe: () => void } - private _notifier = 0 + private _capturedState?: Partial> + private _capturedSnapshot?: TableState private _hasSelector = false private _latestSelector?: (state: TableState) => unknown private _lastSelected: unknown @@ -173,15 +185,29 @@ export class TableController< this._table = constructTable(mergedOptions) + this._rootSource = createRenderPhaseSource>( + this._table.store, + shallow, + ) + // Set up subscriptions immediately when table is created this._setupSubscriptions() } - // Update options when they change - this._table.setOptions((prev) => ({ - ...prev, - ...tableOptions, - })) + // Stage current options for same-render table reads. Publication happens + // in hostUpdated() after Lit commits this render. + table_setOptions( + this._table, + (prev) => ({ + ...prev, + ...tableOptions, + }), + { syncExternalState: false }, + ) + + this._capturedState = this._table.options.state + const renderSnapshot = this._rootSource!.get() + this._capturedSnapshot = renderSnapshot // Record the latest selector each render pass and re-baseline what the // store-subscription gate compares against, so renders triggered by @@ -192,44 +218,31 @@ export class TableController< this._latestSelector = selector as | ((state: TableState) => unknown) | undefined - this._lastSelected = selector - ? selector(this._table.store.state) - : undefined - - // Capture for closure - const tableInstance = this._table + this._lastSelected = selector ? selector(renderSnapshot) : renderSnapshot return { ...this._table, subscribe, FlexRender, get state() { - return (selector?.(tableInstance.store.state) ?? - tableInstance.store.state) as TSelected + return (selector?.(renderSnapshot) ?? renderSnapshot) as TSelected }, } as unknown as LitTable } private _setupSubscriptions() { if (this._table && !this._storeSubscription) { - this._storeSubscription = this._table.store.subscribe(() => { + this._storeSubscription = this._rootSource!.subscribe((state) => { // With a selector, only update the host when the selected state // actually changes (shallow compare). No selector keeps the previous // behavior of updating on every state change. if (this._hasSelector) { - const nextSelected = this._latestSelector!(this._table!.store.state) + const nextSelected = this._latestSelector!(state) if (shallow(this._lastSelected as any, nextSelected as any)) { return } this._lastSelected = nextSelected } - this._notifier++ - this.host.requestUpdate() - }) - - // Options changes (e.g. new data) must always re-render. - this._optionsSubscription = this._table.optionsStore!.subscribe(() => { - this._notifier++ this.host.requestUpdate() }) } @@ -239,10 +252,20 @@ export class TableController< this._setupSubscriptions() } + hostUpdated() { + if (!this._table) { + return + } + this._rootSource!.markCommitted(this._capturedSnapshot!) + table_publishExternalState( + this._table, + this._capturedState ?? null, + shallow, + ) + } + hostDisconnected() { this._storeSubscription?.unsubscribe() this._storeSubscription = undefined - this._optionsSubscription?.unsubscribe() - this._optionsSubscription = undefined } } diff --git a/packages/lit-table/src/reactivity.ts b/packages/lit-table/src/reactivity.ts index fe4403acfc..0aead58a14 100644 --- a/packages/lit-table/src/reactivity.ts +++ b/packages/lit-table/src/reactivity.ts @@ -1,41 +1,20 @@ import { batch, createAtom } from '@tanstack/lit-store' -import type { - TableAtomOptions, - TableReactivityBindings, -} from '@tanstack/table-core/reactivity' +import { renderPhaseReactivity } from '@tanstack/table-core/reactivity' +import type { RenderPhaseReactivityBindings } from '@tanstack/table-core/reactivity' + +export type LitTableReactivityBindings = RenderPhaseReactivityBindings /** * Creates the table-core reactivity bindings used by the Lit adapter. * - * Lit uses TanStack Store atoms directly. `TableController` subscribes to the - * resulting table store and options store to request host updates. + * Lit calls `controller.table(options)` from the host's `render()`, so options + * are plain values synchronized during the update cycle — writing a reactive + * options store there would schedule a second update per interaction. The + * render-phase preset supplies the live readonly-atom facades and the `commit` + * hook; `TableController` publishes its captured controlled state from + * `hostUpdated()`. Store primitives come from `@tanstack/lit-store` so all + * atoms share one store instance with user-provided external atoms. */ -export function litReactivity(): TableReactivityBindings { - return { - createOptionsStore: true, - wrapExternalAtoms: false, - addSubscription: () => { - throw new Error( - 'Feature not supported in current reactivity implementation', - ) - }, - unmount: () => { - throw new Error( - 'Feature not supported in current reactivity implementation', - ) - }, - schedule: (fn) => queueMicrotask(() => fn()), - batch, - untrack: (fn) => fn(), - createReadonlyAtom: (fn: () => T, options?: TableAtomOptions) => { - return createAtom(() => fn(), { - compare: options?.compare, - }) - }, - createWritableAtom: (value: T, options?: TableAtomOptions) => { - return createAtom(value, { - compare: options?.compare, - }) - }, - } +export function litReactivity(): LitTableReactivityBindings { + return renderPhaseReactivity({ createAtom, batch }) } diff --git a/packages/lit-table/src/subscribe-directive.ts b/packages/lit-table/src/subscribe-directive.ts index 1cebb77b12..e6db99371e 100644 --- a/packages/lit-table/src/subscribe-directive.ts +++ b/packages/lit-table/src/subscribe-directive.ts @@ -109,6 +109,12 @@ export class SubscribeDirective extends AsyncDirective { const shouldReinitialize = !this.initialized || sourceChanged || selectorChanged + // The template closure is recreated by every host render and captures + // values from the enclosing render scope (the table wrapper, row models), + // so always adopt the latest one — a stale closure would keep rendering + // through the previous render's captures on subscription-driven updates. + this.resolvedTemplate = actualTemplate + if (shouldReinitialize) { if (this.initialized) { this.controller?.hostDisconnected() @@ -117,7 +123,6 @@ export class SubscribeDirective extends AsyncDirective { this.latestSource = source this.latestSelector = selector - this.resolvedTemplate = actualTemplate if (!this.controller) { this.controller = new TanStackStoreSelector( @@ -129,12 +134,16 @@ export class SubscribeDirective extends AsyncDirective { this.controller.hostUpdate() this.initialized = true - - return this.resolvedTemplate?.(this.controller.value) } - // Host rerender with same source + selector, so we can skip updating - return noChange + // Always re-render on host-driven updates, even with an unchanged source + // and selector: the template may read values that changed without a state + // notification (e.g. row models after the host received new data). The + // subscription still drives updates between host renders; skipping here + // would pin the island to stale non-state inputs. + return this.resolvedTemplate?.( + this.latestSelector!(this.latestSource!.get()), + ) } /** Cleans up the controller subscription when the directive is removed from the DOM. */ diff --git a/packages/lit-table/tests/unit/selectorGate.test.ts b/packages/lit-table/tests/unit/selectorGate.test.ts index 3094011704..535a20892f 100644 --- a/packages/lit-table/tests/unit/selectorGate.test.ts +++ b/packages/lit-table/tests/unit/selectorGate.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'vitest' -import { columnSizingFeature } from '@tanstack/table-core' +import { columnSizingFeature, rowSortingFeature } from '@tanstack/table-core' import { TableController } from '../../src/TableController' function createHost() { @@ -71,21 +71,96 @@ describe('TableController selector gate', () => { expect(host.updateCount).toBe(before + 2) }) - test('options changes always update the host, even with an empty selector', () => { + test('options passed to a new render pass are readable in that same pass', () => { const host = createHost() const controller = new TableController(host) + const baseOptions = { + features: { columnSizingFeature }, + columns: [], + data: [] as Array<{ id: number }>, + } + controller.table(baseOptions, () => ({})) + + const before = host.updateCount + // Options changes flow through host renders (lit reactive properties), so + // the render pass that hands in new options reads them immediately — no + // store-driven host update is needed or scheduled. const table = controller.table( - { - features: { columnSizingFeature }, - columns: [], - data: [], - }, + { ...baseOptions, data: [{ id: 1 }] }, + () => ({}), + ) + expect(table.options.data).toEqual([{ id: 1 }]) + expect(host.updateCount).toBe(before) + }) + + test('hostUpdated publishes captured controlled state to store subscribers', () => { + const host = createHost() + const controller = new TableController(host) + + const initialSorting = [{ id: 'a', desc: false }] + const baseOptions = { + features: { rowSortingFeature }, + columns: [], + data: [], + } + const table = controller.table( + { ...baseOptions, state: { sorting: initialSorting } }, () => ({}), ) + const nextSorting = [{ id: 'b', desc: true }] + const notifications: Array = [] + const subscription = table.store.subscribe((state: any) => { + notifications.push(state.sorting) + }) + + controller.table( + { ...baseOptions, state: { sorting: nextSorting } }, + () => ({}), + ) + + // The render pass reads the new controlled value through the atoms, but + // nothing is published (and no subscriber notified) until the host + // commits. + expect(table.atoms.sorting.get()).toBe(nextSorting) + expect(table.baseAtoms.sorting.get()).toEqual(initialSorting) + expect(notifications).toEqual([]) + + controller.hostUpdated() + + expect(table.baseAtoms.sorting.get()).toBe(nextSorting) + expect(notifications).toEqual([nextSorting]) + + subscription.unsubscribe() + }) + + test('a no-selector render baselines its commit without another host update', () => { + const host = createHost() + const controller = new TableController(host) + const baseOptions = { + features: { rowSortingFeature }, + columns: [], + data: [], + } + + controller.table({ + ...baseOptions, + state: { sorting: [{ id: 'a', desc: false }] }, + }) + controller.hostUpdated() + const before = host.updateCount - table.setOptions((prev) => ({ ...prev, data: [{ id: 1 }] })) - expect(host.updateCount).toBe(before + 1) + const nextSorting = [{ id: 'b', desc: true }] + const table = controller.table({ + ...baseOptions, + state: { sorting: nextSorting }, + }) + + // Do not read table.state: table() itself must capture the render snapshot. + controller.hostUpdated() + + expect(table.store.get().sorting).toBe(nextSorting) + expect(host.updateCount).toBe(before) }) }) diff --git a/packages/preact-table/src/reactivity.ts b/packages/preact-table/src/reactivity.ts index 57ecd811b3..5fecc253a8 100644 --- a/packages/preact-table/src/reactivity.ts +++ b/packages/preact-table/src/reactivity.ts @@ -1,105 +1,19 @@ import { batch, createAtom } from '@tanstack/preact-store' -import type { - TableAtomOptions, - TableReactivityBindings, -} from '@tanstack/table-core/reactivity' +import { renderPhaseReactivity } from '@tanstack/table-core/reactivity' +import type { RenderPhaseReactivityBindings } from '@tanstack/table-core/reactivity' + +export type PreactTableReactivityBindings = RenderPhaseReactivityBindings /** * Creates the table-core reactivity bindings used by the Preact adapter. * - * Preact stores table state in TanStack Store atoms and leaves options as plain - * resolved data because `useTable` synchronizes options during render. + * Preact stores table state in TanStack Store atoms and leaves options as + * plain resolved data because `useTable` synchronizes options during render. + * The render-phase preset supplies the live readonly-atom facades and the + * `commit` hook; the store primitives are passed in from + * `@tanstack/preact-store` so all atoms share one store instance with + * user-provided external atoms. */ -export function preactReactivity(): TableReactivityBindings { - return { - createOptionsStore: false, - wrapExternalAtoms: false, - addSubscription: () => { - throw new Error( - 'Feature not supported in current reactivity implementation', - ) - }, - unmount: () => { - throw new Error( - 'Feature not supported in current reactivity implementation', - ) - }, - schedule: (fn) => queueMicrotask(() => fn()), - batch, - untrack: (fn) => fn(), - createReadonlyAtom: (fn: () => T, options?: TableAtomOptions) => { - return createAtom(() => fn(), { - compare: options?.compare, - }) - }, - createWritableAtom: (value: T, options?: TableAtomOptions) => { - return createAtom(value, { - compare: options?.compare, - }) - }, - } +export function preactReactivity(): PreactTableReactivityBindings { + return renderPhaseReactivity({ createAtom, batch }) } - -// // TOTO - re-explore preact signals for reactivity -// import { batch, computed, signal, untracked } from '@preact/signals' -// import type { -// TableAtomOptions, -// TableReactivityBindings, -// } from '@tanstack/table-core/reactivity' -// import type { Atom, Observer, ReadonlyAtom } from '@tanstack/preact-store' - -// function observerToCallback( -// observerOrNext: Observer | ((value: T) => void), -// ): (value: T) => void { -// return typeof observerOrNext === 'function' -// ? observerOrNext -// : (value) => observerOrNext.next?.(value) -// } - -// function signalToReadonlyAtom(source: { -// value: T -// subscribe: (observer: (value: T) => void) => () => void -// }): ReadonlyAtom { -// return Object.assign(source, { -// get: () => source.value, -// subscribe: ((observerOrNext: Observer | ((value: T) => void)) => { -// const unsubscribe = source.subscribe(observerToCallback(observerOrNext)) -// return { unsubscribe } -// }) as ReadonlyAtom['subscribe'], -// }) -// } - -// function signalToWritableAtom(source: { -// value: T -// subscribe: (observer: (value: T) => void) => () => void -// }): Atom { -// return Object.assign(source, { -// set: (updater: T | ((prevVal: T) => T)) => { -// source.value = -// typeof updater === 'function' -// ? (updater as (prevVal: T) => T)(source.value) -// : updater -// }, -// get: () => source.value, -// subscribe: ((observerOrNext: Observer | ((value: T) => void)) => { -// const unsubscribe = source.subscribe(observerToCallback(observerOrNext)) -// return { unsubscribe } -// }) as Atom['subscribe'], -// }) -// } - -// export function preactReactivity(): TableReactivityBindings { -// return { -// createReadonlyAtom: (fn: () => T, _options?: TableAtomOptions) => { -// return signalToReadonlyAtom(computed(fn)) -// }, -// createWritableAtom: ( -// value: T, -// _options?: TableAtomOptions, -// ): Atom => { -// return signalToWritableAtom(signal(value)) -// }, -// untrack: untracked, -// batch: batch, -// } -// } diff --git a/packages/preact-table/src/useTable.ts b/packages/preact-table/src/useTable.ts index 73c9681d2f..957f0c6fec 100644 --- a/packages/preact-table/src/useTable.ts +++ b/packages/preact-table/src/useTable.ts @@ -1,5 +1,10 @@ -import { useMemo, useState } from 'preact/hooks' +import { useEffect, useLayoutEffect, useMemo, useState } from 'preact/hooks' import { constructTable } from '@tanstack/table-core' +import { createRenderPhaseSource } from '@tanstack/table-core/reactivity' +import { + table_publishExternalState, + table_setOptions, +} from '@tanstack/table-core/static-functions' import { shallow, useSelector } from '@tanstack/preact-store' import { preactReactivity } from './reactivity' import { FlexRender } from './FlexRender' @@ -16,6 +21,9 @@ import type { ComponentChildren } from 'preact' import type { FlexRenderProps } from './FlexRender' import type { SubscribePropsWithStore, SubscribeSource } from './Subscribe' +const useIsomorphicLayoutEffect = + typeof window === 'undefined' ? useEffect : useLayoutEffect + export type PreactTable< TFeatures extends TableFeatures, TData extends RowData, @@ -117,7 +125,7 @@ export function useTable< tableOptions: TableOptions, selector?: (state: TableState) => TSelected, ): PreactTable { - const [table] = useState(() => { + const [{ table, rootSource }] = useState(() => { // Explicit type arguments skip generic inference from the spread object (a // type-check hot spot); the spread only adds the preact reactivity binding // to `features`. @@ -130,26 +138,47 @@ export function useTable< }) as unknown as PreactTable tableInstance.Subscribe = ((props: any) => { - const source = props.source ?? tableInstance.store - return Subscribe({ ...props, - source, + source: props.source ?? tableInstance.store, }) }) as PreactTable['Subscribe'] tableInstance.FlexRender = FlexRender - return tableInstance + return { + table: tableInstance, + // Only a host render that commits advances this source's notification + // baseline. Reads from abandoned renders remain speculative. + rootSource: createRenderPhaseSource>( + tableInstance.store, + shallow, + ), + } }) - // sync options on every render - table.setOptions((prev) => ({ - ...prev, - ...tableOptions, - })) + const coreTable = table as unknown as Table - const state = useSelector(table.store, selector, { compare: shallow }) + // Keep options current during render without publishing them to reactive + // subscribers. Readonly atoms expose the staged snapshot through live get(). + table_setOptions( + coreTable, + (prev) => ({ + ...prev, + ...tableOptions, + }), + { syncExternalState: false }, + ) + + const controlledState = coreTable.options.state + const renderSnapshot = rootSource.get() + + const state = useSelector(rootSource, selector, { compare: shallow }) + + useIsomorphicLayoutEffect(() => { + rootSource.markCommitted(renderSnapshot) + table_publishExternalState(coreTable, controlledState ?? null, shallow) + }) return useMemo( () => ({ diff --git a/packages/preact-table/tests/unit/useTable.test.tsx b/packages/preact-table/tests/unit/useTable.test.tsx new file mode 100644 index 0000000000..08a07f89f5 --- /dev/null +++ b/packages/preact-table/tests/unit/useTable.test.tsx @@ -0,0 +1,222 @@ +import { render } from 'preact' +import { useState } from 'preact/hooks' +import { act } from 'preact/test-utils' +import { + createPaginatedRowModel, + rowPaginationFeature, + tableFeatures, +} from '@tanstack/table-core' +import { afterEach, describe, expect, it } from 'vitest' +import { useTable } from '../../src/useTable' +import type { ColumnDef, PaginationState } from '@tanstack/table-core' +import type { PreactTable } from '../../src/useTable' + +const features = tableFeatures({ + rowPaginationFeature, + paginatedRowModel: createPaginatedRowModel(), +}) + +type TestRow = { + id: number +} + +const data: ReadonlyArray = Array.from({ length: 100 }, (_, id) => ({ + 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 clickButton() { + container?.querySelector('button')?.dispatchEvent( + new MouseEvent('click', { + bubbles: true, + }), + ) +} + +afterEach(() => { + if (container) { + act(() => { + render(null, container!) + }) + container.remove() + container = undefined + } +}) + +describe('useTable controlled state', () => { + it('renders each controlled update in a single pass with consistent reads', () => { + const renderSnapshots: Array<{ + controlled: number + selected: number + atom: number + store: number + firstRow: number + }> = [] + + function ControlledPaginationHarness() { + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }) + const table = useTable( + { + features, + columns, + data, + state: { + pagination, + }, + onPaginationChange: setPagination, + }, + (state) => state.pagination, + ) + + renderSnapshots.push({ + controlled: pagination.pageIndex, + selected: table.state.pageIndex, + atom: table.atoms.pagination.get().pageIndex, + store: table.store.get().pagination.pageIndex, + firstRow: table.getRowModel().rows[0]?.original.id ?? -1, + }) + + return + } + + mount() + expect(renderSnapshots).toHaveLength(1) + + act(() => { + clickButton() + }) + // One render pass per controlled update: the root subscription must not + // forward the post-commit publication back into the owner. + expect(renderSnapshots).toHaveLength(2) + + act(() => { + clickButton() + }) + expect(renderSnapshots).toHaveLength(3) + + // Controlled prop, selected state, slice atom, aggregate store, and the + // row model agree within every render pass. + expect( + renderSnapshots.every( + ({ controlled, selected, atom, store, firstRow }) => + controlled === selected && + selected === atom && + atom === store && + firstRow === controlled * 10, + ), + ).toBe(true) + expect(renderSnapshots).toEqual([ + { + controlled: 0, + selected: 0, + atom: 0, + store: 0, + firstRow: 0, + }, + { + controlled: 1, + selected: 1, + atom: 1, + store: 1, + firstRow: 10, + }, + { + controlled: 2, + selected: 2, + atom: 2, + store: 2, + firstRow: 20, + }, + ]) + }) + + it('notifies external store subscribers exactly once per controlled update, after commit', () => { + let latestTable: + | PreactTable + | undefined + let harnessRenderCount = 0 + + function PublicationHarness() { + harnessRenderCount++ + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }) + const table = useTable( + { + features, + columns, + data, + state: { + pagination, + }, + onPaginationChange: setPagination, + }, + (state) => state.pagination, + ) + latestTable = table + + return + } + + mount() + + const notifications: Array = [] + const subscription = latestTable!.store.subscribe((state) => { + notifications.push(state.pagination.pageIndex) + }) + + act(() => { + clickButton() + }) + + // The unfiltered public store still publishes the committed snapshot for + // isolated consumers, without re-rendering the owner a second time. + expect(notifications).toEqual([1]) + expect(harnessRenderCount).toBe(2) + + subscription.unsubscribe() + }) + + it('still re-renders for uncontrolled internal updates', () => { + let harnessRenderCount = 0 + + function UncontrolledHarness() { + harnessRenderCount++ + const table = useTable( + { + features, + columns, + data, + }, + (state) => state.pagination.pageIndex, + ) + + return + } + + mount() + expect(harnessRenderCount).toBe(1) + expect(container?.querySelector('button')?.textContent).toBe('0') + + act(() => { + clickButton() + }) + + expect(harnessRenderCount).toBe(2) + expect(container?.querySelector('button')?.textContent).toBe('1') + }) +}) diff --git a/packages/react-table/package.json b/packages/react-table/package.json index 4dd8d1c0bb..6bbecfc097 100644 --- a/packages/react-table/package.json +++ b/packages/react-table/package.json @@ -55,10 +55,12 @@ "devDependencies": { "@eslint-react/eslint-plugin": "^5.9.2", "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.3", "eslint-plugin-react-compiler": "19.1.0-rc.2", "eslint-plugin-react-hooks": "^7.1.1", - "react": "^19.2.7" + "react": "^19.2.7", + "react-dom": "^19.2.7" }, "peerDependencies": { "react": ">=18" diff --git a/packages/react-table/src/reactivity.ts b/packages/react-table/src/reactivity.ts index bd86dab0fc..fdc9e174df 100644 --- a/packages/react-table/src/reactivity.ts +++ b/packages/react-table/src/reactivity.ts @@ -1,41 +1,18 @@ import { batch, createAtom } from '@tanstack/react-store' -import type { - TableAtomOptions, - TableReactivityBindings, -} from '@tanstack/table-core/reactivity' +import { renderPhaseReactivity } from '@tanstack/table-core/reactivity' +import type { RenderPhaseReactivityBindings } from '@tanstack/table-core/reactivity' + +export type ReactTableReactivityBindings = RenderPhaseReactivityBindings /** * Creates the table-core reactivity bindings used by the React adapter. * * React stores table state in TanStack Store atoms and leaves options as plain - * resolved data because `useTable` synchronizes options during render. + * resolved data because `useTable` synchronizes options during render. The + * render-phase preset supplies the live readonly-atom facades and the `commit` + * hook; the store primitives are passed in from `@tanstack/react-store` so all + * atoms share one store instance with user-provided external atoms. */ -export function reactReactivity(): TableReactivityBindings { - return { - createOptionsStore: false, - wrapExternalAtoms: false, - addSubscription: () => { - throw new Error( - 'Feature not supported in current reactivity implementation', - ) - }, - unmount: () => { - throw new Error( - 'Feature not supported in current reactivity implementation', - ) - }, - schedule: (fn) => queueMicrotask(fn), - batch, - untrack: (fn) => fn(), - createReadonlyAtom: (fn: () => T, options?: TableAtomOptions) => { - return createAtom(() => fn(), { - compare: options?.compare, - }) - }, - createWritableAtom: (value: T, options?: TableAtomOptions) => { - return createAtom(value, { - compare: options?.compare, - }) - }, - } +export function reactReactivity(): ReactTableReactivityBindings { + return renderPhaseReactivity({ createAtom, batch }) } diff --git a/packages/react-table/src/useTable.ts b/packages/react-table/src/useTable.ts index 4f4ee68fc3..7e4a839d6e 100644 --- a/packages/react-table/src/useTable.ts +++ b/packages/react-table/src/useTable.ts @@ -1,7 +1,12 @@ 'use client' -import { useMemo, useState } from 'react' +import { useEffect, useLayoutEffect, useMemo, useState } from 'react' import { constructTable } from '@tanstack/table-core' +import { createRenderPhaseSource } from '@tanstack/table-core/reactivity' +import { + table_publishExternalState, + table_setOptions, +} from '@tanstack/table-core/static-functions' import { shallow, useSelector } from '@tanstack/react-store' import { reactReactivity } from './reactivity' import { FlexRender } from './FlexRender' @@ -18,6 +23,9 @@ import type { } from '@tanstack/table-core' import type { FunctionComponent, ReactNode } from 'react' +const useIsomorphicLayoutEffect = + typeof window === 'undefined' ? useEffect : useLayoutEffect + export type ReactTable< TFeatures extends TableFeatures, TData extends RowData, @@ -146,7 +154,7 @@ export function useTable< tableOptions: TableOptions, selector?: (state: TableState) => TSelected, ): ReactTable { - const [table] = useState(() => { + const [{ table, rootSource }] = useState(() => { // Explicit type arguments skip generic inference from the spread object (a // type-check hot spot); the spread only adds the react reactivity binding // to `features`. @@ -159,26 +167,52 @@ export function useTable< }) as unknown as ReactTable tableInstance.Subscribe = ((props: any) => { - const source = props.source ?? tableInstance.store - return Subscribe({ ...props, - source, + source: props.source ?? tableInstance.store, }) }) as ReactTable['Subscribe'] tableInstance.FlexRender = FlexRender - return tableInstance + return { + table: tableInstance, + // Only a host render that commits advances this source's notification + // baseline. Reads from suspended or abandoned renders remain speculative. + rootSource: createRenderPhaseSource>( + tableInstance.store, + shallow, + ), + } }) - // sync options on every render - table.setOptions((prev) => ({ - ...prev, - ...tableOptions, - })) + const coreTable = table as unknown as Table - const state = useSelector(table.store, selector, { compare: shallow }) + // Keep options current during render without publishing them to reactive + // subscribers. Readonly atoms expose the staged snapshot through live get(). + table_setOptions( + coreTable, + (prev) => ({ + ...prev, + ...tableOptions, + }), + { syncExternalState: false }, + ) + + // Capture this render's controlled state: `table.options` is shared and may + // hold a newer render by the time the effect runs. + const controlledState = coreTable.options.state + const renderSnapshot = rootSource.get() + + const state = useSelector(rootSource, selector, { compare: shallow }) + + useIsomorphicLayoutEffect(() => { + // Establish the owner render's baseline before publication so its root + // subscription drops the matching notification. Isolated subscribers + // still receive the post-commit store update before paint. + rootSource.markCommitted(renderSnapshot) + table_publishExternalState(coreTable, controlledState ?? null, shallow) + }) // we know this is not the most efficient way to return the table, // but it is required for the react compiler to work diff --git a/packages/react-table/tests/test-setup.ts b/packages/react-table/tests/test-setup.ts new file mode 100644 index 0000000000..ffc0b9a963 --- /dev/null +++ b/packages/react-table/tests/test-setup.ts @@ -0,0 +1,3 @@ +Object.assign(globalThis, { + IS_REACT_ACT_ENVIRONMENT: true, +}) diff --git a/packages/react-table/tests/useTable.test.tsx b/packages/react-table/tests/useTable.test.tsx new file mode 100644 index 0000000000..0d9f28e921 --- /dev/null +++ b/packages/react-table/tests/useTable.test.tsx @@ -0,0 +1,1004 @@ +import * as React from 'react' +import { createRoot } from 'react-dom/client' +import { + createPaginatedRowModel, + rowPaginationFeature, + tableFeatures, +} from '@tanstack/table-core' +import { createAtom } from '@tanstack/react-store' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useTable } from '../src/useTable' +import type { ColumnDef, PaginationState } from '@tanstack/table-core' +import type { ReactTable } from '../src/useTable' +import type { Root } from 'react-dom/client' + +const features = tableFeatures({ + rowPaginationFeature, + paginatedRowModel: createPaginatedRowModel(), +}) + +type TestRow = { + id: number +} + +type NullSelectedTable = ReactTable + +const data: ReadonlyArray = Array.from({ length: 100 }, (_, id) => ({ + id, +})) +const columns: ReadonlyArray> = [] + +const IsolatedPaginationSubscriber = React.memo( + function IsolatedPaginationSubscriber({ + Subscribe, + }: { + Subscribe: NullSelectedTable['Subscribe'] + }) { + return ( + state.pagination.pageIndex}> + {(pageIndex) => ( + {pageIndex} + )} + + ) + }, +) + +let container: HTMLDivElement | undefined +let root: Root | undefined + +function render(element: React.ReactNode) { + container = document.createElement('div') + document.body.append(container) + root = createRoot(container) + + React.act(() => { + root!.render(element) + }) +} + +afterEach(() => { + if (root) { + React.act(() => { + root!.unmount() + }) + } + + container?.remove() + container = undefined + root = undefined + vi.restoreAllMocks() +}) + +describe('useTable state subscriptions', () => { + it('reads controlled state from the live table store', () => { + let harnessRenderCount = 0 + let setControlledPagination: React.Dispatch< + React.SetStateAction + > + let storeSubscribe: ReturnType | undefined + + function ControlledStateHarness() { + harnessRenderCount++ + const [pagination, setPagination] = React.useState({ + pageIndex: 0, + pageSize: 10, + }) + setControlledPagination = setPagination + + const table = useTable( + { + features, + columns, + data, + state: { + pagination, + }, + }, + (state) => state.pagination.pageIndex, + ) + + // useSyncExternalStore subscribes after render. Installing the spy here + // distinguishes the root adapter subscription from table construction. + storeSubscribe ??= vi.spyOn(table.store, 'subscribe') + + return ( + + {table.state} + + ) + } + + render() + + expect(storeSubscribe).toHaveBeenCalledTimes(1) + expect(harnessRenderCount).toBe(1) + + React.act(() => { + setControlledPagination!({ + pageIndex: 1, + pageSize: 10, + }) + }) + + expect( + container?.querySelector('[data-testid="controlled-source-page-index"]') + ?.textContent, + ).toBe('1') + expect(harnessRenderCount).toBe(2) + expect(storeSubscribe).toHaveBeenCalledTimes(1) + }) + + it('resolves changes in slice ownership', () => { + let harnessRenderCount = 0 + + function OwnershipChangeHarness() { + harnessRenderCount++ + const [controlled, setControlled] = React.useState(true) + const [pagination] = React.useState({ + pageIndex: 5, + pageSize: 10, + }) + const table = useTable( + { + features, + columns, + data, + state: controlled + ? { + pagination, + } + : undefined, + }, + (state) => state.pagination, + ) + + return ( + <> + + state.pagination.pageIndex}> + {(pageIndex) => ( + {pageIndex} + )} + + + + ) + } + + render() + + expect( + container?.querySelector('[data-testid="committed-page-index"]') + ?.textContent, + ).toBe('0') + const storeNotifications: Array = [] + const subscription = committedTable!.store.subscribe((state) => { + storeNotifications.push(state.pagination.pageIndex) + }) + + React.act(() => { + container?.querySelector('button')?.dispatchEvent( + new MouseEvent('click', { + bubbles: true, + }), + ) + }) + + // The previous UI is still committed while the transition is suspended. + expect( + container?.querySelector('[data-testid="committed-page-index"]') + ?.textContent, + ).toBe('0') + expect( + container?.querySelector('[data-testid="isolated-page-index"]') + ?.textContent, + ).toBe('0') + const committedBasePageIndex = + committedTable!.baseAtoms.pagination.get().pageIndex + + subscription.unsubscribe() + + expect(committedBasePageIndex).toBe(0) + expect(storeNotifications).toEqual([]) + }) + + it('keeps imperative table.setOptions eager outside render', () => { + let latestTable: ReactTable + + function ImperativeOptionsHarness() { + const table = useTable( + { + features, + columns, + data, + }, + (state) => state.pagination, + ) + latestTable = table + + return ( + <> + + {table.state.pageIndex} + + state.pagination.pageIndex}> + {(pageIndex) => ( + {pageIndex} + )} + + + ) + } + + render() + + const notifications: Array = [] + const subscription = latestTable!.store.subscribe((state) => { + notifications.push(state.pagination.pageIndex) + }) + const pagination = { pageIndex: 3, pageSize: 10 } + + React.act(() => { + latestTable!.setOptions((options) => ({ + ...options, + state: { ...options.state, pagination }, + })) + }) + + expect(latestTable!.baseAtoms.pagination.get()).toBe(pagination) + expect(latestTable!.store.get().pagination).toBe(pagination) + expect( + container?.querySelector('[data-testid="imperative-page-index"]') + ?.textContent, + ).toBe('3') + expect( + container?.querySelector('[data-testid="imperative-subscriber"]') + ?.textContent, + ).toBe('3') + expect(notifications).toEqual([3]) + + subscription.unsubscribe() + }) + + it('publishes controlled pagination after commit without a render-phase update', () => { + const renderSnapshots: Array<{ + controlled: number + selected: number + atom: number + store: number + stableStoreSnapshot: boolean + firstRow: number + }> = [] + + function ControlledPaginationHarness() { + const [pagination, setPagination] = React.useState({ + pageIndex: 0, + pageSize: 10, + }) + const table = useTable( + { + features, + columns, + data, + state: { + pagination, + }, + onPaginationChange: setPagination, + }, + (state) => state, + ) + + const firstStoreSnapshot = table.store.get() + const secondStoreSnapshot = table.store.get() + + renderSnapshots.push({ + controlled: pagination.pageIndex, + selected: table.state.pagination.pageIndex, + atom: table.atoms.pagination.get().pageIndex, + store: firstStoreSnapshot.pagination.pageIndex, + stableStoreSnapshot: Object.is(firstStoreSnapshot, secondStoreSnapshot), + firstRow: table.getRowModel().rows[0]?.original.id ?? -1, + }) + + return ( + <> + + {table.state.pagination.pageIndex} + + + {table.atoms.pagination.get().pageIndex} + + state.pagination.pageIndex}> + {(pageIndex) => ( + {pageIndex} + )} + + + + ) + } + + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + + render( + + + , + ) + + const pageIndex = () => + container?.querySelector('[data-testid="page-index"]')?.textContent + const atomPageIndex = () => + container?.querySelector('[data-testid="atom-page-index"]')?.textContent + const subscribedPageIndex = () => + container?.querySelector('[data-testid="subscribed-page-index"]') + ?.textContent + const nextPage = () => + container?.querySelector('button')?.dispatchEvent( + new MouseEvent('click', { + bubbles: true, + }), + ) + + expect(pageIndex()).toBe('0') + expect(atomPageIndex()).toBe('0') + expect(subscribedPageIndex()).toBe('0') + React.act(() => { + nextPage() + }) + expect(pageIndex()).toBe('1') + expect(atomPageIndex()).toBe('1') + expect(subscribedPageIndex()).toBe('1') + + React.act(() => { + nextPage() + }) + expect(pageIndex()).toBe('2') + expect(atomPageIndex()).toBe('2') + expect(subscribedPageIndex()).toBe('2') + expect(renderSnapshots).toContainEqual({ + controlled: 1, + selected: 1, + atom: 1, + store: 1, + stableStoreSnapshot: true, + firstRow: 10, + }) + expect(renderSnapshots).toContainEqual({ + controlled: 2, + selected: 2, + atom: 2, + store: 2, + stableStoreSnapshot: true, + firstRow: 20, + }) + expect( + renderSnapshots.every( + ({ + controlled, + selected, + atom, + store, + stableStoreSnapshot, + firstRow, + }) => + controlled === selected && + selected === atom && + atom === store && + stableStoreSnapshot && + firstRow === controlled * 10, + ), + ).toBe(true) + + const errors = consoleError.mock.calls.flat().map(String).join('\n') + + expect(errors).not.toContain('Cannot update a component') + }) + + it('updates an isolated subscriber after rapid controlled updates', () => { + function IsolatedControlledPaginationHarness() { + const [pagination, setPagination] = React.useState({ + pageIndex: 0, + pageSize: 10, + }) + const table = useTable( + { + features, + columns, + data, + state: { + pagination, + }, + onPaginationChange: setPagination, + }, + () => null, + ) + + return ( + <> + + {pagination.pageIndex} + + + + + ) + } + + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + + render() + + const controlledPageIndex = () => + container?.querySelector('[data-testid="controlled-page-index"]') + ?.textContent + const isolatedPageIndex = () => + container?.querySelector('[data-testid="isolated-page-index"]') + ?.textContent + + expect(controlledPageIndex()).toBe('0') + expect(isolatedPageIndex()).toBe('0') + + React.act(() => { + container?.querySelector('button')?.dispatchEvent( + new MouseEvent('click', { + bubbles: true, + }), + ) + }) + + expect(controlledPageIndex()).toBe('3') + expect(isolatedPageIndex()).toBe('3') + + const errors = consoleError.mock.calls.flat().map(String).join('\n') + + expect(errors).not.toContain('Cannot update a component') + }) + + it('does not re-render when an uncontrolled update misses the selected slice', () => { + let harnessRenderCount = 0 + + function SelectedPageIndexHarness() { + harnessRenderCount++ + + const table = useTable( + { + features, + columns, + data, + }, + (state) => state.pagination.pageIndex, + ) + + return ( + <> + {table.state} + + {harnessRenderCount} + + state.pagination.pageSize}> + {(pageSize) => ( + {pageSize} + )} + + + + + ) + } + + render() + + const selectedPageIndex = () => + container?.querySelector('[data-testid="selected-page-index"]') + ?.textContent + const subscribedPageSize = () => + container?.querySelector('[data-testid="subscribed-page-size"]') + ?.textContent + + expect(harnessRenderCount).toBe(1) + expect(selectedPageIndex()).toBe('0') + expect(subscribedPageSize()).toBe('10') + + React.act(() => { + container + ?.querySelector('[data-action="resize-page"]') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(harnessRenderCount).toBe(1) + expect(selectedPageIndex()).toBe('0') + expect(subscribedPageSize()).toBe('20') + + React.act(() => { + container + ?.querySelector('[data-action="next-page"]') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(harnessRenderCount).toBe(2) + expect(selectedPageIndex()).toBe('1') + }) + + it('settles when a controlled slice is recreated during an unrelated render', () => { + let harnessRenderCount = 0 + + function RecreatedControlledSliceHarness() { + harnessRenderCount++ + const [pagination, setPagination] = React.useState({ + pageIndex: 0, + pageSize: 10, + }) + const [tick, setTick] = React.useState(0) + const table = useTable( + { + features, + columns, + data, + state: { + pagination: { ...pagination }, + }, + onPaginationChange: setPagination, + }, + (state) => state, + ) + + return ( + <> + {tick} + + {table.state.pagination.pageIndex} + + + {table.atoms.pagination.get().pageIndex} + + + + + ) + } + + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + + render() + + expect(harnessRenderCount).toBe(1) + + React.act(() => { + container + ?.querySelector('[data-action="unrelated-update"]') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect( + container?.querySelector('[data-testid="unrelated-tick"]')?.textContent, + ).toBe('1') + expect( + container?.querySelector('[data-testid="recreated-page-index"]') + ?.textContent, + ).toBe('0') + expect(harnessRenderCount).toBe(2) + + React.act(() => { + container + ?.querySelector('[data-action="controlled-next-page"]') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect( + container?.querySelector('[data-testid="recreated-page-index"]') + ?.textContent, + ).toBe('1') + expect( + container?.querySelector('[data-testid="recreated-atom-page-index"]') + ?.textContent, + ).toBe('1') + expect(harnessRenderCount).toBe(3) + + const errors = consoleError.mock.calls.flat().map(String).join('\n') + + expect(errors).not.toContain('Maximum update depth exceeded') + }) + + it('returns the selected slice and still reacts to uncontrolled updates', () => { + function UncontrolledPaginationHarness() { + const table = useTable( + { + features, + columns, + data, + }, + (state) => ({ + pageIndex: state.pagination.pageIndex, + }), + ) + + return ( + <> + {table.state.pageIndex} + + {JSON.stringify(table.state)} + + + + ) + } + + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + + render() + + const pageIndex = () => + container?.querySelector('[data-testid="page-index"]')?.textContent + const selectedState = () => + container?.querySelector('[data-testid="selected-state"]')?.textContent + const nextPage = () => + container?.querySelector('button')?.dispatchEvent( + new MouseEvent('click', { + bubbles: true, + }), + ) + + expect(selectedState()).toBe('{"pageIndex":0}') + expect(pageIndex()).toBe('0') + React.act(() => { + nextPage() + }) + expect(pageIndex()).toBe('1') + + React.act(() => { + nextPage() + }) + expect(pageIndex()).toBe('2') + + const errors = consoleError.mock.calls.flat().map(String).join('\n') + + expect(errors).not.toContain('The result of getSnapshot should be cached') + }) +}) diff --git a/packages/react-table/vite.config.ts b/packages/react-table/vite.config.ts index 42096c8406..92d9339465 100644 --- a/packages/react-table/vite.config.ts +++ b/packages/react-table/vite.config.ts @@ -10,5 +10,6 @@ export default defineConfig({ watch: false, environment: 'jsdom', globals: true, + setupFiles: ['./tests/test-setup.ts'], }, }) diff --git a/packages/table-core/src/core/cells/constructCell.ts b/packages/table-core/src/core/cells/constructCell.ts index 65c393c938..63e16ac737 100644 --- a/packages/table-core/src/core/cells/constructCell.ts +++ b/packages/table-core/src/core/cells/constructCell.ts @@ -52,7 +52,7 @@ export function constructCell< cell.row = row // Initialize instance-specific data for features that need it - const initFns = table._cellInstanceInitFns! + const initFns = table._cellInstanceInitFns for (let i = 0; i < initFns.length; i++) { initFns[i]!(cell as Cell) } diff --git a/packages/table-core/src/core/columns/constructColumn.ts b/packages/table-core/src/core/columns/constructColumn.ts index e3eb5bfcb8..b26f42f7d6 100644 --- a/packages/table-core/src/core/columns/constructColumn.ts +++ b/packages/table-core/src/core/columns/constructColumn.ts @@ -117,7 +117,7 @@ export function constructColumn< column.parent = parent // Initialize instance-specific data for features that need it - const initFns = table._columnInstanceInitFns! + const initFns = table._columnInstanceInitFns for (let i = 0; i < initFns.length; i++) { initFns[i]!(column as Column) } diff --git a/packages/table-core/src/core/headers/buildHeaderGroups.ts b/packages/table-core/src/core/headers/buildHeaderGroups.ts index ff3391913a..bd913d189e 100644 --- a/packages/table-core/src/core/headers/buildHeaderGroups.ts +++ b/packages/table-core/src/core/headers/buildHeaderGroups.ts @@ -51,7 +51,7 @@ export function buildHeaderGroups< const headerGroups: Array> = [] - const headerGroupInitFns = table._headerGroupInstanceInitFns! + const headerGroupInitFns = table._headerGroupInstanceInitFns const constructHeaderGroup = ( headersToGroup: Array>, diff --git a/packages/table-core/src/core/headers/constructHeader.ts b/packages/table-core/src/core/headers/constructHeader.ts index 6cb5b2b4b3..d8f1bb5361 100644 --- a/packages/table-core/src/core/headers/constructHeader.ts +++ b/packages/table-core/src/core/headers/constructHeader.ts @@ -64,7 +64,7 @@ export function constructHeader< header.subHeaders = [] // Initialize instance-specific data for features that need it - const initFns = table._headerInstanceInitFns! + const initFns = table._headerInstanceInitFns for (let i = 0; i < initFns.length; i++) { initFns[i]!(header as Header) } diff --git a/packages/table-core/src/core/reactivity/coreReactivityFeature.types.ts b/packages/table-core/src/core/reactivity/coreReactivityFeature.types.ts index c69b74b239..045d0dd90a 100644 --- a/packages/table-core/src/core/reactivity/coreReactivityFeature.types.ts +++ b/packages/table-core/src/core/reactivity/coreReactivityFeature.types.ts @@ -22,6 +22,13 @@ export interface TableAtomOptions extends AtomOptions { export interface TableReactivityBindings { createOptionsStore: boolean wrapExternalAtoms: boolean + /** + * Invalidates readonly atoms whose compute reads non-reactive inputs (plain + * options). Render-phase adapters call this after publishing captured + * controlled state from a host commit, including when no base atom changed, + * so controlled ownership changes still reach subscribers. + */ + commit?: () => void addSubscription: (subscription: Subscription) => void /** * Creates a writable atom with an initial value. diff --git a/packages/table-core/src/core/reactivity/renderPhaseReactivity.ts b/packages/table-core/src/core/reactivity/renderPhaseReactivity.ts new file mode 100644 index 0000000000..42b30a20f7 --- /dev/null +++ b/packages/table-core/src/core/reactivity/renderPhaseReactivity.ts @@ -0,0 +1,164 @@ +import type { Atom, AtomOptions, ReadonlyAtom } from '@tanstack/store' +import type { + TableAtomOptions, + TableReactivityBindings, +} from './coreReactivityFeature.types' + +/** + * Reactivity bindings for adapters whose options are plain values synchronized + * during the host framework's render phase, with a guaranteed `commit` hook. + */ +export interface RenderPhaseReactivityBindings extends TableReactivityBindings { + commit: () => void +} + +/** + * Store primitives supplied by the adapter. + * + * They MUST come from the adapter's own store package (e.g. + * `@tanstack/react-store`) rather than table-core's copy: dependency tracking + * and batching share module-global state, so atoms created here must live in + * the same store instance as user-provided external atoms and adapter + * subscriptions. + */ +export interface RenderPhaseReactivityPrimitives { + createAtom: { + (getValue: (prev?: T) => T, options?: AtomOptions): ReadonlyAtom + (initialValue: T, options?: AtomOptions): Atom + } + batch: (fn: () => void) => void + /** + * Overrides the deferred-scheduling primitive (defaults to + * `queueMicrotask`). + */ + schedule?: (fn: () => void) => void +} + +/** + * Creates reactivity bindings for render-phase adapters (React, Preact, Lit): + * frameworks with plain, non-reactive options that are re-synchronized during + * component render, where store notifications must not fire until the host + * commits. + * + * Readonly atoms are exposed as live facades. `get()` re-evaluates the + * resolver against the options of the render in progress — a normal computed + * cannot know that plain `options.state` changed — and caches the result + * through the configured comparator so external-store consumers (e.g. React's + * `useSyncExternalStore`) see referentially stable snapshots. `subscribe()` + * goes through a hidden computed that tracks the resolver's real atom + * dependencies plus a commit version, so subscribers are invalidated by + * actual reactive writes and by the adapter's post-commit publication. + * + * @example + * ```ts + * import { batch, createAtom } from '@tanstack/react-store' + * + * export const reactReactivity = () => + * renderPhaseReactivity({ createAtom, batch }) + * ``` + */ +export function renderPhaseReactivity( + primitives: RenderPhaseReactivityPrimitives, +): RenderPhaseReactivityBindings { + const { createAtom, batch } = primitives + const commitAtom = createAtom(0) + + return { + createOptionsStore: false, + wrapExternalAtoms: false, + addSubscription: () => { + throw new Error( + 'Feature not supported in current reactivity implementation', + ) + }, + unmount: () => { + throw new Error( + 'Feature not supported in current reactivity implementation', + ) + }, + schedule: primitives.schedule ?? ((fn) => queueMicrotask(fn)), + batch, + untrack: (fn) => fn(), + createReadonlyAtom: (fn: () => T, atomOptions?: TableAtomOptions) => { + const compare = atomOptions?.compare ?? Object.is + let hasSnapshot = false + let snapshot: T + + const getSnapshot = () => { + const nextSnapshot = fn() + + if (!hasSnapshot || !compare(snapshot, nextSnapshot)) { + snapshot = nextSnapshot + hasSnapshot = true + } + + return snapshot + } + + const reactiveAtom = createAtom( + () => { + commitAtom.get() + return getSnapshot() + }, + { compare }, + ) + + return { + get: getSnapshot, + subscribe: reactiveAtom.subscribe.bind(reactiveAtom), + } + }, + createWritableAtom: (value: T, atomOptions?: TableAtomOptions) => { + return createAtom(value, { + compare: atomOptions?.compare, + }) + }, + commit: () => { + commitAtom.set((version) => version + 1) + }, + } +} + +type SelectionSource = { + get: () => T + subscribe: (listener: (value: T) => void) => { unsubscribe: () => void } +} + +export interface RenderPhaseSource extends SelectionSource { + /** + * Records the snapshot observed by a render that actually committed. + */ + markCommitted: (snapshot: T) => void +} + +/** + * Creates a render-phase source with an explicit commit baseline. + * + * Render-phase adapters publish controlled state after the host framework + * commits so isolated subscribers update, but the component that owns the + * table already rendered that exact snapshot — forwarding the notification to + * its root subscription would produce a redundant render. Unlike a last-read + * filter, speculative reads do not change notification behavior: only + * `markCommitted()` advances the baseline. + */ +export function createRenderPhaseSource( + source: SelectionSource, + compare: (committed: T, published: T) => boolean = Object.is, +): RenderPhaseSource { + let hasCommittedSnapshot = false + let committedSnapshot: T + + return { + get: source.get, + markCommitted: (snapshot) => { + committedSnapshot = snapshot + hasCommittedSnapshot = true + }, + subscribe: (listener) => + source.subscribe((value) => { + if (!hasCommittedSnapshot || !compare(committedSnapshot, value)) { + listener(value) + } + }), + } +} diff --git a/packages/table-core/src/core/rows/constructRow.ts b/packages/table-core/src/core/rows/constructRow.ts index a950f00505..9fecbb23ed 100644 --- a/packages/table-core/src/core/rows/constructRow.ts +++ b/packages/table-core/src/core/rows/constructRow.ts @@ -59,7 +59,7 @@ export const constructRow = < row.subRows = subRows ?? [] // Initialize instance-specific data (e.g., caches) for features that need it - const initFns = table._rowInstanceInitFns! + const initFns = table._rowInstanceInitFns for (let i = 0; i < initFns.length; i++) { initFns[i]!(row as Row) } diff --git a/packages/table-core/src/core/table/constructTable.ts b/packages/table-core/src/core/table/constructTable.ts index 64a29c75ec..100bafd7e7 100644 --- a/packages/table-core/src/core/table/constructTable.ts +++ b/packages/table-core/src/core/table/constructTable.ts @@ -1,5 +1,6 @@ +import { shallow } from '@tanstack/store' import { coreFeatures } from '../coreFeatures' -import { cloneState } from '../../utils' +import { cloneState, hasOwn } from '../../utils' import { atomToStore } from '../reactivity/coreReactivityFeature.utils' import { table_syncExternalStateToBaseAtoms } from './coreTablesFeature.utils' import type { Atom } from '@tanstack/store' @@ -57,13 +58,19 @@ export function constructTable< ...features } = tableOptions.features + // pre-compute the init functions to make the other constructors faster const table = { - _reactivity, + _cellInstanceInitFns: [], + _columnInstanceInitFns: [], _features: { ...coreFeatures, ...features }, - _rowModels: {}, + _headerGroupInstanceInitFns: [], + _headerInstanceInitFns: [], + _reactivity, + _rowInstanceInitFns: [], _rowModelFns: { aggregationFns, filterFns, sortFns }, - baseAtoms: {}, + _rowModels: {}, atoms: {}, + baseAtoms: {}, } as unknown as Table_Internal const featuresList: Array = Object.values(table._features) @@ -133,18 +140,29 @@ export function constructTable< debugName: `table/baseAtoms/${key}`, }, ) as any - - // create readonly derived atom: on each get(), read either external atom or base atom ;(table.atoms as any)[key] = _reactivity.createReadonlyAtom( () => { - const externalAtoms = table.options.atoms as - | Partial>> - | undefined + const options = table.options + const externalAtoms = options.atoms const externalAtom = externalAtoms?.[key] + // Always touch the reactive owner so controlled state still has an + // invalidation source when it is published after a framework commit. + const reactiveState = externalAtom + ? externalAtom.get() + : // @ts-ignore - looping through stateKeys so we know the key is defined + table.baseAtoms[key].get() + if (externalAtom) { - return externalAtom.get() + return reactiveState } - return table.baseAtoms[key]!.get() + + const controlledState = options.state as + | Record + | undefined + + return controlledState && hasOwn(controlledState, key) + ? controlledState[key] + : reactiveState }, { debugName: `table/atoms/${key}` }, ) @@ -158,59 +176,47 @@ export function constructTable< const snapshot = {} as TableState & TableState_All for (let i = 0; i < stateKeys.length; i++) { const key = stateKeys[i]! - ;(snapshot as Record)[key] = table.atoms[key]!.get() + // @ts-ignore - looping through stateKeys so we know the key is defined + ;(snapshot as Record)[key] = table.atoms[key].get() } return snapshot }, - { debugName: 'table/store' }, + { + compare: shallow, + debugName: 'table/store', + }, ), ) - // pre-compute the init functions to make the other constructors faster - const cellInstanceInitFns: Array< - NonNullable - > = [] - const columnInstanceInitFns: Array< - NonNullable - > = [] - const headerGroupInstanceInitFns: Array< - NonNullable - > = [] - const headerInstanceInitFns: Array< - NonNullable - > = [] - const rowInstanceInitFns: Array< - NonNullable - > = [] - for (let i = 0; i < featuresList.length; i++) { const feature = featuresList[i]! + feature.initTableInstanceData?.(table) if (feature.initCellInstanceData) { - cellInstanceInitFns.push(feature.initCellInstanceData.bind(feature)) + table._cellInstanceInitFns.push( + feature.initCellInstanceData.bind(feature), + ) } if (feature.initColumnInstanceData) { - columnInstanceInitFns.push(feature.initColumnInstanceData.bind(feature)) + table._columnInstanceInitFns.push( + feature.initColumnInstanceData.bind(feature), + ) } if (feature.initHeaderGroupInstanceData) { - headerGroupInstanceInitFns.push( + table._headerGroupInstanceInitFns.push( feature.initHeaderGroupInstanceData.bind(feature), ) } if (feature.initHeaderInstanceData) { - headerInstanceInitFns.push(feature.initHeaderInstanceData.bind(feature)) + table._headerInstanceInitFns.push( + feature.initHeaderInstanceData.bind(feature), + ) } if (feature.initRowInstanceData) { - rowInstanceInitFns.push(feature.initRowInstanceData.bind(feature)) + table._rowInstanceInitFns.push(feature.initRowInstanceData.bind(feature)) } - feature.initTableInstanceData?.(table) + feature.constructTableAPIs?.(table) } - table._cellInstanceInitFns = cellInstanceInitFns - table._columnInstanceInitFns = columnInstanceInitFns - table._headerGroupInstanceInitFns = headerGroupInstanceInitFns - table._headerInstanceInitFns = headerInstanceInitFns - table._rowInstanceInitFns = rowInstanceInitFns - if ( process.env.NODE_ENV === 'development' && (tableOptions.debugAll || tableOptions.debugTable) @@ -243,9 +249,5 @@ export function constructTable< ) } - for (let i = 0; i < featuresList.length; i++) { - featuresList[i]!.constructTableAPIs?.(table) - } - return table as unknown as Table } diff --git a/packages/table-core/src/core/table/coreTablesFeature.types.ts b/packages/table-core/src/core/table/coreTablesFeature.types.ts index 7dfaf87e80..715455cc67 100644 --- a/packages/table-core/src/core/table/coreTablesFeature.types.ts +++ b/packages/table-core/src/core/table/coreTablesFeature.types.ts @@ -164,9 +164,7 @@ export interface Table_CoreProperties< /** * Cache of the `initCellInstanceData` functions for features that define one. */ - _cellInstanceInitFns?: Array< - NonNullable - > + _cellInstanceInitFns: Array> /** * Prototype cache for Cell objects - shared by all cells in this table */ @@ -174,7 +172,7 @@ export interface Table_CoreProperties< /** * Cache of the `initColumnInstanceData` functions for features that define one. */ - _columnInstanceInitFns?: Array< + _columnInstanceInitFns: Array< NonNullable > /** @@ -188,13 +186,13 @@ export interface Table_CoreProperties< /** * Cache of the `initHeaderGroupInstanceData` functions for features that define one. */ - _headerGroupInstanceInitFns?: Array< + _headerGroupInstanceInitFns: Array< NonNullable > /** * Cache of the `initHeaderInstanceData` functions for features that define one. */ - _headerInstanceInitFns?: Array< + _headerInstanceInitFns: Array< NonNullable > /** @@ -216,7 +214,7 @@ export interface Table_CoreProperties< /** * Cache of the `initRowInstanceData` functions for features that define one. */ - _rowInstanceInitFns?: Array> + _rowInstanceInitFns: Array> /** * The readonly derived atoms for each `TableState` slice. Each derives from * its corresponding `baseAtom` plus, optionally, a per-slice external atom or diff --git a/packages/table-core/src/core/table/coreTablesFeature.utils.ts b/packages/table-core/src/core/table/coreTablesFeature.utils.ts index 5003aa6cd4..73f3aea4b5 100644 --- a/packages/table-core/src/core/table/coreTablesFeature.utils.ts +++ b/packages/table-core/src/core/table/coreTablesFeature.utils.ts @@ -3,42 +3,84 @@ import type { RowData, Updater } from '../../types/type-utils' import type { TableFeatures } from '../../types/TableFeatures' import type { Table_Internal } from '../../types/Table' import type { TableOptions } from '../../types/TableOptions' +import type { TableState } from '../../types/TableState' /** * Synchronizes externally controlled state slices into the table's base atoms. * - * This keeps legacy `options.state` values reflected in the atom graph so - * derived atoms, stores, and table APIs read a consistent snapshot. + * This keeps `options.state` values mirrored in the atom graph so derived + * atoms, stores, and table APIs read a consistent snapshot. + * + * Adapters that update options during their host's render phase pass the + * state snapshot captured by the committed render as `capturedState` — the + * shared options object may already hold values from a newer render that + * never commits. Pass `null` to publish nothing (a captured "no controlled + * state"); omitting the argument reads the current `table.options.state` + * instead. An optional `compare` suppresses semantically unchanged slice + * writes; the default remains reference equality. * * @example * ```ts * table_syncExternalStateToBaseAtoms(table) + * table_syncExternalStateToBaseAtoms(table, capturedState ?? null, shallow) * ``` */ export function table_syncExternalStateToBaseAtoms< TFeatures extends TableFeatures, TData extends RowData, ->(table: Table_Internal): void { - const state = table.options.state - if (!state) { - return - } +>( + table: Table_Internal, + capturedState?: Partial> | null, + compare: (currentState: unknown, externalState: unknown) => boolean = ( + currentState, + externalState, + ) => currentState === externalState, +): void { + const state = + capturedState === undefined ? table.options.state : capturedState table._reactivity.batch(() => { - for (const key in state) { - const baseAtom = (table.baseAtoms as Record)[key] - if (!baseAtom) { - continue - } + if (state) { + for (const key in state) { + const baseAtom = (table.baseAtoms as Record)[key] + if (!baseAtom) { + continue + } - const externalState = state[key as keyof typeof state] - if (externalState !== table._reactivity.untrack(() => baseAtom.get())) { - baseAtom.set(() => externalState) + const externalState = state[key as keyof typeof state] + const currentState = table._reactivity.untrack(() => baseAtom.get()) + if (!compare(currentState, externalState)) { + baseAtom.set(() => externalState) + } } } }) } +/** + * Publishes captured controlled state after a host framework commits. + * + * Render-phase adapters stage options without synchronizing base atoms, then + * pass the state captured by the committed render here. The commit signal also + * invalidates ownership changes when no base atom was written. + */ +export function table_publishExternalState< + TFeatures extends TableFeatures, + TData extends RowData, +>( + table: Table_Internal, + state: Partial> | null, + compare: (currentState: unknown, externalState: unknown) => boolean = ( + currentState, + externalState, + ) => currentState === externalState, +): void { + table._reactivity.batch(() => { + table_syncExternalStateToBaseAtoms(table, state, compare) + table._reactivity.commit?.() + }) +} + /** * Resets all internal table base atoms to `table.initialState`, then clears * transient instance data through registered feature reset hooks. @@ -147,6 +189,7 @@ export function table_mergeOptions< * @example * ```ts * table_setOptions(table, (old) => old) + * table_setOptions(table, (old) => old, { syncExternalState: false }) * ``` */ export function table_setOptions< @@ -155,6 +198,9 @@ export function table_setOptions< >( table: Table_Internal, updater: Updater>, + options?: { + syncExternalState?: boolean + }, ): void { const newOptions = functionalUpdate( updater, @@ -167,5 +213,7 @@ export function table_setOptions< } else { table.options = mergedOptions } - table_syncExternalStateToBaseAtoms(table) + if (options?.syncExternalState !== false) { + table_publishExternalState(table, mergedOptions.state ?? null) + } } diff --git a/packages/table-core/src/features/column-grouping/createGroupedRowModel.ts b/packages/table-core/src/features/column-grouping/createGroupedRowModel.ts index ef3da12d96..a0511fb33e 100644 --- a/packages/table-core/src/features/column-grouping/createGroupedRowModel.ts +++ b/packages/table-core/src/features/column-grouping/createGroupedRowModel.ts @@ -29,6 +29,10 @@ export function createGroupedRowModel< >(): (table: Table) => () => RowModel { return (_table) => { const table = _table as unknown as Table_Internal + let hasAutoResetDependencies = false + let previousGrouping: unknown + let previousPreGroupedRowModel: RowModel | undefined + return tableMemo({ feature: 'columnGroupingFeature', table, @@ -40,8 +44,25 @@ export function createGroupedRowModel< ], fn: () => _createGroupedRowModel(table), onAfterUpdate: () => { - table_autoResetExpanded(table) - table_autoResetPageIndex(table) + const grouping = table.atoms.grouping?.get() + const preGroupedRowModel = table.getPreGroupedRowModel() + const rowInputsChanged = + !hasAutoResetDependencies || + grouping !== previousGrouping || + preGroupedRowModel !== previousPreGroupedRowModel + + previousGrouping = grouping + previousPreGroupedRowModel = preGroupedRowModel + hasAutoResetDependencies = true + + // Column definitions participate in grouped-row computation (for + // grouping and aggregation metadata), but changing only their + // reference does not change which rows belong on the current page or + // which group ids are expanded. + if (rowInputsChanged) { + table_autoResetExpanded(table) + table_autoResetPageIndex(table) + } }, }) } diff --git a/packages/table-core/src/reactivity.ts b/packages/table-core/src/reactivity.ts index 83329678f6..ca7a98ddbe 100644 --- a/packages/table-core/src/reactivity.ts +++ b/packages/table-core/src/reactivity.ts @@ -1,2 +1,3 @@ export * from './core/reactivity/coreReactivityFeature.types' export * from './core/reactivity/coreReactivityFeature.utils' +export * from './core/reactivity/renderPhaseReactivity' diff --git a/packages/table-core/src/types/TableFeatures.ts b/packages/table-core/src/types/TableFeatures.ts index 10fe36c252..50de9d3fbc 100644 --- a/packages/table-core/src/types/TableFeatures.ts +++ b/packages/table-core/src/types/TableFeatures.ts @@ -426,10 +426,13 @@ export interface TableFeature { * instance. * * This runs once during table construction after options, state atoms, and - * the store are available, and before any feature's `constructTableAPIs` - * hook runs. Use `constructTableAPIs` exclusively for assigning table - * methods. Table resets do not rerun this hook; use - * `resetTableInstanceData` to clear transient instance data instead. + * the store are available. Features are processed in a single pass in + * registration order, with each feature's instance data initialized just + * before its own `constructTableAPIs` hook, so this hook may rely on data + * and APIs of features registered earlier. Use `constructTableAPIs` + * exclusively for assigning table methods. Table resets do not rerun this + * hook; use `resetTableInstanceData` to clear transient instance data + * instead. */ initTableInstanceData?: < TFeatures extends TableFeatures, diff --git a/packages/table-core/tests/implementation/core/autoReset.test.ts b/packages/table-core/tests/implementation/core/autoReset.test.ts index 4bd28fe8c4..aaabebd342 100644 --- a/packages/table-core/tests/implementation/core/autoReset.test.ts +++ b/packages/table-core/tests/implementation/core/autoReset.test.ts @@ -196,6 +196,21 @@ describe('autoResetPageIndex end-to-end wiring', () => { expect(table.atoms.pagination.get().pageIndex).toBe(2) }) + it('should not reset pageIndex when only the column definitions reference changes', async () => { + const table = makeTable() + await primeTable(table) + + table.setPageIndex(2) + table.setOptions((old) => ({ + ...old, + columns: [...old.columns], + })) + table.getRowModel() + await flushMicrotasks() + + expect(table.atoms.pagination.get().pageIndex).toBe(2) + }) + describe('option precedence', () => { async function triggerReset(table: ReturnType) { await primeTable(table) diff --git a/packages/table-core/tests/unit/core/renderPhaseReactivity.test.ts b/packages/table-core/tests/unit/core/renderPhaseReactivity.test.ts new file mode 100644 index 0000000000..cebfc2fddb --- /dev/null +++ b/packages/table-core/tests/unit/core/renderPhaseReactivity.test.ts @@ -0,0 +1,167 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { batch, createAtom } from '@tanstack/store' +import { constructTable, rowSortingFeature } from '../../../src' +import { + createRenderPhaseSource, + renderPhaseReactivity, +} from '../../../src/reactivity' +import { + table_publishExternalState, + table_setOptions, + table_syncExternalStateToBaseAtoms, +} from '../../../src/static-functions' +import { testFeatures } from '../../fixtures/features' +import type { SortingState, Table_Internal } from '../../../src' + +const features = testFeatures({ + rowSortingFeature, +}) + +function makeDeferredTable() { + const bindings = renderPhaseReactivity({ createAtom, batch }) + const table = constructTable({ + features: { + ...features, + coreReactivityFeature: bindings, + }, + columns: [], + data: [], + }) + + return { + bindings, + table, + internalTable: table as unknown as Table_Internal, + } +} + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('renderPhaseReactivity bindings', () => { + it('invokes the commit hook on publish, including when nothing is published', () => { + const { bindings, internalTable } = makeDeferredTable() + const commitSpy = vi.spyOn(bindings, 'commit') + + table_publishExternalState(internalTable, { + sorting: [{ id: 'a', desc: false }], + }) + expect(commitSpy).toHaveBeenCalledTimes(1) + + // Publishing options with no controlled state still bumps the commit + // version so ownership releases invalidate subscribers. + table_publishExternalState(internalTable, null) + expect(commitSpy).toHaveBeenCalledTimes(2) + }) + + it('treats null capturedState as "publish nothing" and omitted as "read current options"', () => { + const { table, internalTable } = makeDeferredTable() + const controlled: SortingState = [{ id: 'controlled', desc: false }] + + table_setOptions( + internalTable, + (options) => ({ + ...options, + state: { sorting: controlled }, + }), + { syncExternalState: false }, + ) + + table_syncExternalStateToBaseAtoms(internalTable, null) + expect(table.baseAtoms.sorting.get()).toEqual([]) + + table_syncExternalStateToBaseAtoms(internalTable) + expect(table.baseAtoms.sorting.get()).toBe(controlled) + }) + + it('keeps public setOptions eager', () => { + const { table, internalTable } = makeDeferredTable() + const controlled: SortingState = [{ id: 'controlled', desc: false }] + const notifications: Array = [] + const subscription = table.atoms.sorting.subscribe((value) => { + notifications.push(value) + }) + + table_setOptions(internalTable, (options) => ({ + ...options, + state: { sorting: controlled }, + })) + + expect(table.baseAtoms.sorting.get()).toBe(controlled) + expect(table.atoms.sorting.get()).toBe(controlled) + expect(notifications).toEqual([controlled]) + + subscription.unsubscribe() + }) + + it('suppresses semantically equal slice writes through the compare parameter', () => { + const { table, internalTable } = makeDeferredTable() + const sorting: SortingState = [{ id: 'a', desc: false }] + + table_syncExternalStateToBaseAtoms(internalTable, { sorting }) + const published = table.baseAtoms.sorting.get() + + const recreated = [...sorting] + table_syncExternalStateToBaseAtoms( + internalTable, + { sorting: recreated }, + (currentState, externalState) => + JSON.stringify(currentState) === JSON.stringify(externalState), + ) + + expect(table.baseAtoms.sorting.get()).toBe(published) + }) +}) + +describe('createRenderPhaseSource', () => { + it('filters against explicit commits, not speculative reads', () => { + const listeners: Array<(value: { n: number }) => void> = [] + let snapshot = { n: 1 } + const source = { + get: () => snapshot, + subscribe: (listener: (value: { n: number }) => void) => { + listeners.push(listener) + return { unsubscribe: () => {} } + }, + } + + const filtered = createRenderPhaseSource(source) + const received: Array<{ n: number }> = [] + filtered.subscribe((value) => received.push(value)) + + // No render has committed yet, so the notification is forwarded. + listeners[0]!(snapshot) + expect(received).toEqual([{ n: 1 }]) + + filtered.markCommitted(snapshot) + listeners[0]!(snapshot) + expect(received).toHaveLength(1) + + // Reading a speculative snapshot does not move the commit baseline. + snapshot = { n: 2 } + expect(filtered.get()).toBe(snapshot) + listeners[0]!(snapshot) + expect(received).toEqual([{ n: 1 }, { n: 2 }]) + + filtered.markCommitted(snapshot) + listeners[0]!(snapshot) + expect(received).toHaveLength(2) + }) + + it('does not filter other subscribers of the underlying source', () => { + const atom = createAtom({ n: 0 }) + const filtered = createRenderPhaseSource(atom) + + const direct: Array = [] + atom.subscribe((value) => direct.push(value.n)) + + filtered.markCommitted(filtered.get()) + const next = { n: 1 } + atom.set(next) + filtered.markCommitted(filtered.get()) + atom.set({ n: 2 }) + + expect(direct).toEqual([1, 2]) + }) +}) diff --git a/packages/table-core/tests/unit/core/table/constructTable.test.ts b/packages/table-core/tests/unit/core/table/constructTable.test.ts index adecafbfe1..14ba3060ed 100644 --- a/packages/table-core/tests/unit/core/table/constructTable.test.ts +++ b/packages/table-core/tests/unit/core/table/constructTable.test.ts @@ -35,7 +35,7 @@ function getterOnlyMerge(...sources: Array) { } describe('constructTable', () => { - it('initializes feature-owned table data before constructing any table APIs', () => { + it('runs each feature init just before its own API construction, in registration order', () => { const calls: Array = [] const initA = vi.fn((table: any) => { calls.push('init-a') @@ -43,7 +43,9 @@ describe('constructTable', () => { expect(table.baseAtoms.lifecycle.get()).toBe('initial') expect(table.atoms.lifecycle.get()).toBe('initial') expect(table.store.state.lifecycle).toBe('initial') - expect(table.reset).toBeUndefined() + // Hooks may rely on features registered earlier: the core features' + // APIs (like reset) are already constructed by the time this runs. + expect(table.reset).toBeTypeOf('function') table.firstInitialized = true }) const initB = vi.fn((table: any) => { @@ -53,8 +55,9 @@ describe('constructTable', () => { }) const apiA = vi.fn((table: any) => { calls.push('api-a') + // Own feature's instance data is initialized, later features' is not. expect(table.firstInitialized).toBe(true) - expect(table.secondInitialized).toBe(true) + expect(table.secondInitialized).toBeUndefined() }) const apiB = vi.fn((table: any) => { calls.push('api-b') @@ -94,8 +97,8 @@ describe('constructTable', () => { expect(calls).toEqual([ 'init-a', - 'init-b', 'api-a', + 'init-b', 'api-b', 'api-without-init', ]) diff --git a/packages/table-core/tests/unit/core/tableAtoms.test.ts b/packages/table-core/tests/unit/core/tableAtoms.test.ts index 441be055f4..316519fb65 100644 --- a/packages/table-core/tests/unit/core/tableAtoms.test.ts +++ b/packages/table-core/tests/unit/core/tableAtoms.test.ts @@ -1,17 +1,23 @@ import { describe, expect, it, vi } from 'vitest' -import { createAtom } from '@tanstack/store' +import { batch, createAtom } from '@tanstack/store' import { constructTable, rowPaginationFeature, rowSelectionFeature, rowSortingFeature, } from '../../../src' +import { renderPhaseReactivity } from '../../../src/reactivity' +import { + table_setOptions, + table_syncExternalStateToBaseAtoms, +} from '../../../src/static-functions' import { testFeatures } from '../../fixtures/features' import type { PaginationState, SortingState, Table, TableOptions, + Table_Internal, } from '../../../src' const features = testFeatures({ @@ -68,6 +74,46 @@ describe('three-layer atom architecture', () => { expect(table.store.state.sorting).toEqual(external) }) + it('stages render options without publishing controlled state', () => { + const table = constructTable({ + features: { + ...features, + coreReactivityFeature: renderPhaseReactivity({ createAtom, batch }), + }, + columns: [], + data: [], + }) + const internalTable = table as unknown as Table_Internal< + typeof features, + any + > + const controlled: SortingState = [{ id: 'controlled', desc: false }] + + table_setOptions( + internalTable, + (options) => ({ + ...options, + state: { + sorting: controlled, + }, + }), + { syncExternalState: false }, + ) + + // Options are current and render reads resolve the controlled value, + // but nothing was published into the base atoms yet. + expect(table.options.state?.sorting).toBe(controlled) + expect(table.baseAtoms.sorting.get()).toEqual([]) + expect(table.atoms.sorting.get()).toBe(controlled) + expect(table.store.get().sorting).toBe(controlled) + + table_syncExternalStateToBaseAtoms(internalTable, { + sorting: controlled, + }) + + expect(table.baseAtoms.sorting.get()).toBe(controlled) + }) + it('options.atoms[key] takes precedence over options.state[key] when both are present', () => { const externalAtom = createAtom([ { id: 'fromAtom', desc: true }, diff --git a/playwright.config.ts b/playwright.config.ts index e6013b3f87..e8b89f9447 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,8 +1,27 @@ +import path from 'node:path' import { defineConfig, devices } from '@playwright/test' +const testDir = process.env.PLAYWRIGHT_TEST_DIR ?? './tests/e2e' + +// Every example runs its own Playwright process with PLAYWRIGHT_TEST_DIR set to +// `/tests/e2e`, so name the project after the example. Without this, +// each reporter line reads `[chromium] > smoke.spec.ts` and a failure in the +// release audit job does not say which of the ~343 examples broke. +function getProjectName() { + if (!process.env.PLAYWRIGHT_TEST_DIR) return 'chromium' + + const exampleDir = path.resolve(testDir, '..', '..') + + return `${path.basename(path.dirname(exampleDir))}/${path.basename(exampleDir)}` +} + export default defineConfig({ - testDir: process.env.PLAYWRIGHT_TEST_DIR ?? './tests/e2e', - fullyParallel: true, + testDir, + // The unit of parallelism is the spec file, not the test. Each example's spec + // starts one dev server in `beforeAll` and shares it across its tests; with + // `fullyParallel` every test becomes its own job, so CI's two workers would + // split a single file and start that server twice. + fullyParallel: false, timeout: 60_000, expect: { timeout: 10_000, @@ -16,7 +35,7 @@ export default defineConfig({ }, projects: [ { - name: 'chromium', + name: getProjectName(), use: { ...devices['Desktop Chrome'] }, }, ], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f00cfac038..e6ece96525 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13497,6 +13497,9 @@ importers: '@types/react': specifier: 19.2.16 version: 19.2.16 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.16) '@vitejs/plugin-react': specifier: ^6.0.3 version: 6.0.3(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(rolldown@1.1.5)(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)))(babel-plugin-react-compiler@1.0.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.16))(terser@5.46.2)(yaml@2.9.0)) @@ -13509,6 +13512,9 @@ importers: react: specifier: ^19.2.7 version: 19.2.7 + react-dom: + specifier: ^19.2.7 + version: 19.2.7(react@19.2.7) packages/react-table-devtools: dependencies: