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/packages/lit-table/src/TableController.ts b/packages/lit-table/src/TableController.ts index 458d156e17..85d8743609 100644 --- a/packages/lit-table/src/TableController.ts +++ b/packages/lit-table/src/TableController.ts @@ -1,4 +1,6 @@ import { constructTable } from '@tanstack/table-core' +import { createCommitFilteredSource } from '@tanstack/table-core/reactivity' +import { table_syncExternalStateToBaseAtoms } from '@tanstack/table-core/static-functions' import { shallow } from '@tanstack/lit-store' import { litReactivity } from './reactivity' import { FlexRender } from './flexRender' @@ -122,8 +124,14 @@ export class TableController< host: ReactiveControllerHost private _table: Table | null = null + private _rootSource?: { + get: () => TableState + subscribe: (listener: (value: TableState) => void) => { + unsubscribe: () => void + } + } private _storeSubscription?: { unsubscribe: () => void } - private _optionsSubscription?: { unsubscribe: () => void } + private _capturedState?: Partial> private _notifier = 0 private _hasSelector = false private _latestSelector?: (state: TableState) => unknown @@ -173,16 +181,34 @@ export class TableController< this._table = constructTable(mergedOptions) + // The commit publication in hostUpdated() re-notifies `table.store` so + // `table.subscribe` islands update, but the host render already read + // that exact snapshot — forwarding the notification here would update + // the host once per controlled change just to find nothing changed. + // Only the controller's own subscription is filtered. + this._rootSource = createCommitFilteredSource>( + this._table.store, + ) + // Set up subscriptions immediately when table is created this._setupSubscriptions() } - // Update options when they change + // Update options when they change. The reactivity bindings declare + // `deferExternalStateSync`, so no store subscriber is notified while the + // host is still rendering; the readonly atoms expose fresh controlled + // state through their live get() in the meantime. Publication happens in + // hostUpdated(). this._table.setOptions((prev) => ({ ...prev, ...tableOptions, })) + // Capture this render's controlled state: `table.options` is a shared + // mutable object, and by the time hostUpdated() runs it may hold values + // from a newer render pass. + this._capturedState = this._table.options.state + // Record the latest selector each render pass and re-baseline what the // store-subscription gate compares against, so renders triggered by // anything else (e.g. a @state change) reset the gate too. The gate lets @@ -193,31 +219,30 @@ export class TableController< | ((state: TableState) => unknown) | undefined this._lastSelected = selector - ? selector(this._table.store.state) + ? selector(this._rootSource!.get()) : undefined // Capture for closure - const tableInstance = this._table + const rootSource = this._rootSource! return { ...this._table, subscribe, FlexRender, get state() { - return (selector?.(tableInstance.store.state) ?? - tableInstance.store.state) as TSelected + return (selector?.(rootSource.get()) ?? rootSource.get()) 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 } @@ -226,12 +251,6 @@ export class TableController< 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 +258,23 @@ export class TableController< this._setupSubscriptions() } + hostUpdated() { + if (!this._table) { + return + } + // Publish the controlled state captured by the committed render so + // `table.subscribe` islands and other store subscribers observe it. Core + // batches the writes and bumps the commit version, which also handles + // changes in controlled/uncontrolled ownership. + table_syncExternalStateToBaseAtoms( + 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..ef385363d7 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 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..beed695f58 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,67 @@ 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) + }) - const before = host.updateCount - table.setOptions((prev) => ({ ...prev, data: [{ id: 1 }] })) - expect(host.updateCount).toBe(before + 1) + 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() }) }) 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..0e849ade33 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 { 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' @@ -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`. @@ -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>( + tableInstance.store, + ), + } }) - // sync options on every render - table.setOptions((prev) => ({ + const coreTable = table as unknown as Table + + // 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( () => ({ 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..36c7f658a0 --- /dev/null +++ b/packages/preact-table/tests/unit/useTable.test.tsx @@ -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 = 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[2]).toMatchObject({ controlled: 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/src/reactivity.ts b/packages/react-table/src/reactivity.ts index 1e7cfdbaf9..fdc9e174df 100644 --- a/packages/react-table/src/reactivity.ts +++ b/packages/react-table/src/reactivity.ts @@ -1,150 +1,18 @@ -import { batch, createAtom, useSelector } from '@tanstack/react-store' -import { useEffect, useLayoutEffect, useRef, useState } from 'react' -import type { Subscription } from '@tanstack/react-store' -import type { - TableAtomOptions, - TableReactivityBindings, -} from '@tanstack/table-core/reactivity' +import { batch, createAtom } from '@tanstack/react-store' +import { renderPhaseReactivity } from '@tanstack/table-core/reactivity' +import type { RenderPhaseReactivityBindings } from '@tanstack/table-core/reactivity' -interface ExternalSource { - get: () => T - subscribe: (listener: (value: T) => void) => Subscription -} - -export const useIsomorphicLayoutEffect = - typeof window === 'undefined' ? useEffect : useLayoutEffect - -export interface ReactTableReactivityBindings extends TableReactivityBindings { - /** - * Invalidates readonly atoms after React commits updated plain options. - */ - commit: () => void -} +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(): ReactTableReactivityBindings { - 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: (fn) => queueMicrotask(fn), - batch, - untrack: (fn) => fn(), - createReadonlyAtom: (fn: () => T, options?: TableAtomOptions) => { - const compare = options?.compare ?? Object.is - let hasSnapshot = false - let snapshot: T - - // The returned atom is a live facade: get() must see the options from the - // render in progress, while subscribe() must only publish after commit. - // The hidden computed tracks both real atom dependencies and commitAtom, - // which covers changes whose only reactive source is a plain option - // (controlled state ownership, for example). - 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, options?: TableAtomOptions) => { - return createAtom(value, { - compare: options?.compare, - }) - }, - commit: () => { - commitAtom.set((version) => version + 1) - }, - } -} - -/** - * Selects the root table state while filtering notifications that merely - * publish a snapshot already read during the current React render. - * - * Isolated `table.Subscribe` consumers still receive the underlying store - * notification; only this hook's root subscription is filtered. - */ -export function useTableSelector( - source: ExternalSource, - selector: ((state: TState) => TSelected) | undefined, - compare: (previous: TSelected, next: TSelected) => boolean, -): TSelected { - const committedSelectorRef = useRef(selector) - const committedSelectionRef = useRef< - | { hasSnapshot: false } - | { - hasSnapshot: true - snapshot: TSelected - } - >({ hasSnapshot: false }) - - const [filteredSource] = useState(() => { - return { - get: () => source.get(), - subscribe: (onStoreChange: (value: TState) => void) => { - return source.subscribe((nextState) => { - const select = - committedSelectorRef.current ?? - ((state: TState) => state as unknown as TSelected) - const nextSelection = select(nextState) - const committedSelection = committedSelectionRef.current - - if ( - !committedSelection.hasSnapshot || - !compare(committedSelection.snapshot, nextSelection) - ) { - onStoreChange(nextState) - } - }) - }, - } - }) - - const selected = useSelector(filteredSource, selector, { compare }) - - useIsomorphicLayoutEffect(() => { - committedSelectorRef.current = selector - committedSelectionRef.current = { - hasSnapshot: true, - snapshot: selected, - } - }) - - return selected + return renderPhaseReactivity({ createAtom, batch }) } diff --git a/packages/react-table/src/useTable.ts b/packages/react-table/src/useTable.ts index b5ae06d637..6da8126cb1 100644 --- a/packages/react-table/src/useTable.ts +++ b/packages/react-table/src/useTable.ts @@ -1,17 +1,14 @@ 'use client' -import { useMemo, useState } from 'react' +import { useEffect, useLayoutEffect, useMemo, useState } from 'react' 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 } from '@tanstack/react-store' -import { - reactReactivity, - useIsomorphicLayoutEffect, - useTableSelector, -} from './reactivity' +import { shallow, useSelector } from '@tanstack/react-store' +import { reactReactivity } from './reactivity' import { FlexRender } from './FlexRender' import { Subscribe } from './Subscribe' import type { FlexRenderProps } from './FlexRender' @@ -26,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, @@ -154,16 +154,14 @@ export function useTable< tableOptions: TableOptions, selector?: (state: TableState) => TSelected, ): ReactTable { - const [{ table, reactivity }] = useState(() => { - const reactivity = reactReactivity() - + 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`. const tableInstance = constructTable({ ...tableOptions, features: { - coreReactivityFeature: reactivity, + coreReactivityFeature: reactReactivity(), ...tableOptions.features, }, }) as unknown as ReactTable @@ -181,37 +179,46 @@ export function useTable< return { table: tableInstance, - reactivity, + // 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>( + tableInstance.store, + ), } }) const coreTable = table as unknown as Table - // Keep non-state options current during render, but publish controlled state - // into the subscribed atom graph only after React commits. - table_setOptions( - coreTable, - (prev) => ({ - ...prev, - ...tableOptions, - }), - { - syncExternalState: false, - }, - ) + // 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 React is still rendering; the readonly atoms expose the + // fresh controlled state through their live get() in the meantime. + table_setOptions(coreTable, (prev) => ({ + ...prev, + ...tableOptions, + })) + // 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 that never commits (e.g. a suspended transition). const controlledState = coreTable.options.state - const state = useTableSelector(table.store, selector, shallow) + + const state = useSelector(rootSource, selector, { compare: shallow }) useIsomorphicLayoutEffect(() => { - // Publish only the state captured by a committed render. The React - // readonly atoms already expose it through their live get(). Invalidating - // in the same pre-paint batch also updates isolated table.Subscribe - // consumers and handles changes in controlled/uncontrolled ownership. - reactivity.batch(() => { - table_syncExternalStateToBaseAtoms(coreTable, controlledState, shallow) - reactivity.commit() - }) + // 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, + ) }) // we know this is not the most efficient way to return the table, diff --git a/packages/table-core/src/core/reactivity/coreReactivityFeature.types.ts b/packages/table-core/src/core/reactivity/coreReactivityFeature.types.ts index c69b74b239..7700c0da75 100644 --- a/packages/table-core/src/core/reactivity/coreReactivityFeature.types.ts +++ b/packages/table-core/src/core/reactivity/coreReactivityFeature.types.ts @@ -22,6 +22,30 @@ export interface TableAtomOptions extends AtomOptions { export interface TableReactivityBindings { createOptionsStore: boolean wrapExternalAtoms: boolean + /** + * Write-timing strategy. When `true`, `table_setOptions` does NOT mirror + * `options.state` into the base atoms; the adapter syncs options during its + * host framework's render phase (where store notifications are illegal or + * wasteful) and publishes the captured controlled state after the host + * commits by calling `table_syncExternalStateToBaseAtoms` itself. + * + * Leave unset (or `false`) for fine-grained adapters whose `setOptions` runs + * in a write-safe reactive context (effects, watchers) — eager sync is + * correct there and keeps same-tick read-after-write consistency. + */ + deferExternalStateSync?: boolean + /** + * Invalidates readonly atoms whose compute reads non-reactive inputs (plain + * `table.options`). Core calls this at the end of + * `table_syncExternalStateToBaseAtoms` — including when nothing was + * published — so resolution changes with no atom write (e.g. a controlled + * slice released back to internal ownership) still reach subscribers. + * + * Required when `deferExternalStateSync` is `true` and `createOptionsStore` + * is `false`: with plain options and deferred publication there is no other + * invalidation source. Adapters with a reactive options store never need it. + */ + 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..9b7eadf61b --- /dev/null +++ b/packages/table-core/src/core/reactivity/renderPhaseReactivity.ts @@ -0,0 +1,163 @@ +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): + * 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 only by + * actual reactive writes or by `commit()` after the host framework commits. + * + * Sets `deferExternalStateSync`, so `table_setOptions` leaves base-atom + * publication to the adapter's post-commit + * `table_syncExternalStateToBaseAtoms` call (which invokes `commit` itself). + * + * @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, + deferExternalStateSync: true, + 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) + }, + } +} + +/** + * Wraps a store-shaped source so notifications that merely republish the + * snapshot the consumer has already read through `get()` are dropped. + * + * 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 make the host re-render it once per controlled + * update just to find nothing changed. Readonly atoms return referentially + * stable snapshots while semantically unchanged, so a reference check + * suffices. + * + * Only the returned source is filtered; other subscribers of the underlying + * store still receive every notification. + */ +export function createCommitFilteredSource(source: { + get: () => T + subscribe: (listener: (value: T) => void) => { unsubscribe: () => void } +}): { + get: () => T + subscribe: (listener: (value: T) => void) => { unsubscribe: () => void } +} { + let lastSeenSnapshot: T | undefined + + return { + get: () => { + lastSeenSnapshot = source.get() + return lastSeenSnapshot + }, + subscribe: (listener) => + source.subscribe((value) => { + if (value !== lastSeenSnapshot) { + listener(value) + } + }), + } +} diff --git a/packages/table-core/src/core/table/coreTablesFeature.utils.ts b/packages/table-core/src/core/table/coreTablesFeature.utils.ts index 977508837e..8ce1ed2044 100644 --- a/packages/table-core/src/core/table/coreTablesFeature.utils.ts +++ b/packages/table-core/src/core/table/coreTablesFeature.utils.ts @@ -8,61 +8,59 @@ 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. - * Passing an explicit state snapshot lets framework adapters publish the state - * captured by a committed render. An optional comparator can suppress - * semantically unchanged slice writes; the default remains `Object.is`. + * 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. + * + * The reactivity `commit` hook (when bound) is invoked inside the same batch, + * even when nothing is published, so resolution changes with no atom write + * (e.g. a controlled slice released back to internal ownership) still + * invalidate subscribers. * * @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 export function table_syncExternalStateToBaseAtoms< TFeatures extends TableFeatures, TData extends RowData, >( table: Table_Internal, - state: Partial> | undefined, - compare?: (currentState: unknown, externalState: unknown) => boolean, -): void -export function table_syncExternalStateToBaseAtoms< - TFeatures extends TableFeatures, - TData extends RowData, ->( - table: Table_Internal, - state?: Partial>, - compare: ( - currentState: unknown, - externalState: unknown, - ) => boolean = Object.is, + capturedState?: Partial> | null, + compare: (currentState: unknown, externalState: unknown) => boolean = ( + currentState, + externalState, + ) => currentState === externalState, ): void { - // The second overload intentionally distinguishes an omitted argument from - // an explicitly captured `undefined` state. - state = arguments.length === 1 ? table.options.state : state - - if (!state) { - return - } + 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] - const currentState = table._reactivity.untrack(() => baseAtom.get()) - if (!compare(currentState, externalState)) { - 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) + } } } + + table._reactivity.commit?.() }) } @@ -171,10 +169,14 @@ export function table_mergeOptions< * The updater receives the current resolved options and the merged result is * immediately assigned to the table instance. * + * When the adapter's reactivity bindings declare `deferExternalStateSync`, + * controlled state is NOT mirrored into the base atoms here — the adapter + * publishes the captured state after its host framework commits by calling + * `table_syncExternalStateToBaseAtoms` itself. + * * @example * ```ts * table_setOptions(table, (old) => old) - * table_setOptions(table, (old) => old, { syncExternalState: false }) * ``` */ export function table_setOptions< @@ -183,9 +185,6 @@ export function table_setOptions< >( table: Table_Internal, updater: Updater>, - options?: { - syncExternalState?: boolean - }, ): void { const newOptions = functionalUpdate( updater, @@ -198,7 +197,7 @@ export function table_setOptions< } else { table.options = mergedOptions } - if (options?.syncExternalState !== false) { + if (!table._reactivity.deferExternalStateSync) { table_syncExternalStateToBaseAtoms(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/tests/unit/core/renderPhaseReactivity.test.ts b/packages/table-core/tests/unit/core/renderPhaseReactivity.test.ts new file mode 100644 index 0000000000..34214ad6fe --- /dev/null +++ b/packages/table-core/tests/unit/core/renderPhaseReactivity.test.ts @@ -0,0 +1,138 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { batch, createAtom } from '@tanstack/store' +import { constructTable, rowSortingFeature } from '../../../src' +import { + createCommitFilteredSource, + renderPhaseReactivity, +} from '../../../src/reactivity' +import { + 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_syncExternalStateToBaseAtoms(internalTable, { + sorting: [{ id: 'a', desc: false }], + }) + expect(commitSpy).toHaveBeenCalledTimes(1) + + // A captured "no controlled state" publishes nothing but still bumps the + // commit version so ownership releases invalidate subscribers. + table_syncExternalStateToBaseAtoms(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 }, + })) + + table_syncExternalStateToBaseAtoms(internalTable, null) + expect(table.baseAtoms.sorting.get()).toEqual([]) + + table_syncExternalStateToBaseAtoms(internalTable) + expect(table.baseAtoms.sorting.get()).toBe(controlled) + }) + + 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('createCommitFilteredSource', () => { + it('drops notifications whose snapshot the consumer already read, forwards the rest', () => { + 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 = createCommitFilteredSource(source) + const received: Array<{ n: number }> = [] + filtered.subscribe((value) => received.push(value)) + + // Never read through the filtered source yet — forwarded. + listeners[0]!(snapshot) + expect(received).toEqual([{ n: 1 }]) + + // Read it, then republish the same reference — dropped. + expect(filtered.get()).toBe(snapshot) + listeners[0]!(snapshot) + expect(received).toHaveLength(1) + + // A new snapshot reference is forwarded again. + snapshot = { n: 2 } + listeners[0]!(snapshot) + expect(received).toEqual([{ n: 1 }, { n: 2 }]) + }) + + it('does not filter other subscribers of the underlying source', () => { + const atom = createAtom({ n: 0 }) + const filtered = createCommitFilteredSource(atom) + + const direct: Array = [] + atom.subscribe((value) => direct.push(value.n)) + + filtered.get() + const next = { n: 1 } + atom.set(next) + filtered.get() + atom.set({ n: 2 }) + + expect(direct).toEqual([1, 2]) + }) +}) diff --git a/packages/table-core/tests/unit/core/tableAtoms.test.ts b/packages/table-core/tests/unit/core/tableAtoms.test.ts index 23191ef22c..cd9cffc048 100644 --- a/packages/table-core/tests/unit/core/tableAtoms.test.ts +++ b/packages/table-core/tests/unit/core/tableAtoms.test.ts @@ -1,11 +1,12 @@ 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, @@ -73,27 +74,30 @@ describe('three-layer atom architecture', () => { expect(table.store.state.sorting).toEqual(external) }) - it('can defer options.state synchronization for framework adapters', () => { - const table = makeTable() + it('defers options.state synchronization when bindings declare deferExternalStateSync', () => { + 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, + table_setOptions(internalTable, (options) => ({ + ...options, + state: { + sorting: controlled, }, - ) + })) + // 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)