Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions examples/preact/basic-external-state/tests/e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
})
46 changes: 12 additions & 34 deletions packages/preact-table/src/reactivity.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,21 @@
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: <T>(fn: () => T, options?: TableAtomOptions<T>) => {
return createAtom(() => fn(), {
compare: options?.compare,
})
},
createWritableAtom: <T>(value: T, options?: TableAtomOptions<T>) => {
return createAtom(value, {
compare: options?.compare,
})
},
}
export function preactReactivity(): PreactTableReactivityBindings {
return renderPhaseReactivity({ createAtom, batch })
}

// // TOTO - re-explore preact signals for reactivity
Expand Down
53 changes: 47 additions & 6 deletions packages/preact-table/src/useTable.ts
Original file line number Diff line number Diff line change
@@ -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 { createCommitFilteredSource } from '@tanstack/table-core/reactivity'
import {
table_setOptions,
table_syncExternalStateToBaseAtoms,
} from '@tanstack/table-core/static-functions'
import { shallow, useSelector } from '@tanstack/preact-store'
import { preactReactivity } from './reactivity'
import { FlexRender } from './FlexRender'
Expand All @@ -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,
Expand Down Expand Up @@ -117,7 +125,7 @@ export function useTable<
tableOptions: TableOptions<TFeatures, TData>,
selector?: (state: TableState<TFeatures>) => TSelected,
): PreactTable<TFeatures, TData, TSelected> {
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`.
Expand All @@ -140,16 +148,49 @@ export function useTable<

tableInstance.FlexRender = FlexRender

return tableInstance
return {
table: tableInstance,
// The commit publication below re-notifies `table.store` so isolated
// `table.Subscribe` consumers update, but this root hook already
// rendered that exact snapshot — forwarding the notification here would
// re-render the owner once per controlled update just to find nothing
// changed. Only this hook's subscription is filtered.
rootSource: createCommitFilteredSource<TableState<TFeatures>>(
tableInstance.store,
),
}
})

// sync options on every render
table.setOptions((prev) => ({
const coreTable = table as unknown as Table<TFeatures, TData>

// Keep options current during render, so render reads (data, columns,
// callbacks, controlled state) never lag a frame behind. The reactivity
// bindings declare `deferExternalStateSync`, so no store subscriber is
// notified while Preact is still rendering; the readonly atoms expose the
// fresh controlled state through their live get() in the meantime.
table_setOptions(coreTable, (prev) => ({
...prev,
...tableOptions,
}))

const state = useSelector(table.store, selector, { compare: shallow })
// Capture this render's controlled state: `table.options` is a shared
// mutable object, and by the time the effect runs it may hold values from a
// newer render.
const controlledState = coreTable.options.state

const state = useSelector(rootSource, selector, { compare: shallow })

useIsomorphicLayoutEffect(() => {
// Publish only the state captured by a committed render. Layout effect
// (not passive) so isolated table.Subscribe consumers update before
// paint. Core batches the writes and bumps the commit version, which also
// handles changes in controlled/uncontrolled ownership.
table_syncExternalStateToBaseAtoms(
coreTable,
controlledState ?? null,
shallow,
)
})

return useMemo(
() => ({
Expand Down
200 changes: 200 additions & 0 deletions packages/preact-table/tests/unit/useTable.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
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<TestRow> = Array.from({ length: 100 }, (_, id) => ({
id,
}))
const columns: ReadonlyArray<ColumnDef<typeof features, TestRow>> = []

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<PaginationState>({
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 <button onClick={() => table.nextPage()}>Next page</button>
}

mount(<ControlledPaginationHarness />)
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[2]).toMatchObject({ controlled: 2, firstRow: 20 })
})

it('notifies external store subscribers exactly once per controlled update, after commit', () => {
let latestTable:
| PreactTable<typeof features, TestRow, PaginationState>
| undefined
let harnessRenderCount = 0

function PublicationHarness() {
harnessRenderCount++
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
})
const table = useTable(
{
features,
columns,
data,
state: {
pagination,
},
onPaginationChange: setPagination,
},
(state) => state.pagination,
)
latestTable = table

return <button onClick={() => table.nextPage()}>Next page</button>
}

mount(<PublicationHarness />)

const notifications: Array<number> = []
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 <button onClick={() => table.nextPage()}>{table.state}</button>
}

mount(<UncontrolledHarness />)
expect(harnessRenderCount).toBe(1)
expect(container?.querySelector('button')?.textContent).toBe('0')

act(() => {
clickButton()
})

expect(harnessRenderCount).toBe(2)
expect(container?.querySelector('button')?.textContent).toBe('1')
})
})
Loading
Loading