From d0221a2f118c8977efa8b27c6f0ec6c19c9610c8 Mon Sep 17 00:00:00 2001 From: riccardoperra Date: Wed, 22 Apr 2026 22:23:13 +0200 Subject: [PATCH 1/5] feat(table): support external reactivity binding --- packages/angular-table/src/injectTable.ts | 182 ++++++++++-------- .../src/lazySignalInitializer.ts | 2 +- .../flex-render/flex-render-table.test.ts | 3 - .../angular-table/tests/injectTable.test.ts | 5 +- packages/angular-table/vite.config.ts | 2 +- .../src/core/table/constructTable.ts | 80 +++++--- .../src/core/table/coreTablesFeature.types.ts | 9 + .../tableReactivityFeature.ts | 93 +++------ 8 files changed, 195 insertions(+), 181 deletions(-) diff --git a/packages/angular-table/src/injectTable.ts b/packages/angular-table/src/injectTable.ts index 06a2bd6531..d3d72b921a 100644 --- a/packages/angular-table/src/injectTable.ts +++ b/packages/angular-table/src/injectTable.ts @@ -7,21 +7,21 @@ import { signal, untracked, } from '@angular/core' -import { - constructReactivityFeature, - constructTable, -} from '@tanstack/table-core' -import { injectSelector } from '@tanstack/angular-store' +import { constructTable } from '@tanstack/table-core' +import { toObservable } from '@angular/core/rxjs-interop' +import { shallow } from '@tanstack/angular-store' import { lazyInit } from './lazySignalInitializer' import type { Atom, ReadonlyAtom } from '@tanstack/angular-store' import type { RowData, Table, + TableAtomOptions, TableFeatures, TableOptions, + TableReactivityBindings, TableState, } from '@tanstack/table-core' -import type { Signal, ValueEqualityFn } from '@angular/core' +import type { Signal, ValueEqualityFn, WritableSignal } from '@angular/core' /** * Store mode: pass `selector` (required) to project from full table state. @@ -60,10 +60,13 @@ export type AngularTable< */ readonly value: Signal> /** - * Alias: **`Subscribe`** — same function reference as `computed` (naming parity with other adapters). + * Creates a computed that subscribe to changes in the table store with a custom selector. + * Default equality function is "shallow". */ - computed: AngularTableComputed - Subscribe: AngularTableComputed + computed: (props: { + selector: (state: TableState) => TSubSelected + equal?: ValueEqualityFn + }) => Signal> } /** @@ -133,106 +136,121 @@ export function injectTable< ): AngularTable { assertInInjectionContext(injectTable) const injector = inject(Injector) - const stateNotifier = signal(0) - const angularReactivityFeature = constructReactivityFeature({ - stateNotifier: () => stateNotifier(), - }) return lazyInit(() => { const resolvedOptions: TableOptions = { ...options(), - _features: { - ...options()._features, - angularReactivityFeature, - }, - } + reactivity: angularReactivity(injector), + } as TableOptions const table = constructTable(resolvedOptions) as AngularTable< TFeatures, TData, TSelected > - const tableState = injectSelector(table.store, (state) => state, { - injector, - }) - const tableOptions = injectSelector(table.optionsStore, (state) => state, { - injector, - }) - - const updatedOptions = computed>(() => { - const tableOptionsValue = options() - const result: TableOptions = { - ...untracked(() => table.options), - ...tableOptionsValue, - _features: { ...tableOptionsValue._features, angularReactivityFeature }, - } - if (tableOptionsValue.state) { - result.state = tableOptionsValue.state - } - return result - }) - - effect( - () => { - const newOptions = updatedOptions() - untracked(() => table.setOptions(newOptions)) - }, - { injector, debugName: 'tableOptionsUpdate' }, - ) let isMount = true effect( () => { - void [tableOptions(), tableState()] - if (!isMount) untracked(() => stateNotifier.update((n) => n + 1)) - isMount && (isMount = false) + const newOptions = options() + if (isMount) { + isMount = false + return + } + untracked(() => + table.setOptions((previous) => ({ + ...previous, + ...newOptions, + })), + ) }, - { injector, debugName: 'tableStateNotifier' }, + { injector, debugName: 'tableOptionsUpdate' }, ) - const computedFn = function computedSubscribe(props: { - source?: Atom | ReadonlyAtom - selector?: (state: unknown) => unknown - equal?: ValueEqualityFn + table.computed = function Subscribe(props: { + selector: (state: TableState) => TSubSelected + equal?: ValueEqualityFn }) { - if (props.source !== undefined) { - return injectSelector( - props.source, - props.selector ?? ((value) => value), - { - injector, - ...(props.equal && { compare: props.equal }), - }, - ) - } - return injectSelector(table.store, props.selector, { - injector, - ...(props.equal && { compare: props.equal }), + return computed(() => props.selector(table.store.get()), { + equal: props.equal, }) } - table.computed = computedFn as AngularTable< - TFeatures, - TData, - TSelected - >['computed'] - table.Subscribe = computedFn as AngularTable< - TFeatures, - TData, - TSelected - >['Subscribe'] Object.defineProperty(table, 'state', { - value: injectSelector(table.store, selector, { injector }), + value: computed(() => selector(table.store.get())), }) Object.defineProperty(table, 'value', { - value: computed(() => { - tableOptions() - tableState() - return table - }), + value: computed( + () => { + table.store.get() + table.optionsStore.get() + return table + }, + { equal: () => false }, + ), }) return table }) } + +function computedToReadonlyAtom( + signal: () => T, + injector: Injector, +): ReadonlyAtom { + const atom: ReadonlyAtom = computed(() => + signal(), + ) as unknown as ReadonlyAtom + atom.get = () => signal() + atom.subscribe = (observer) => { + return toObservable(computed(signal), { + injector: injector, + }).subscribe(observer) + } + return atom +} + +function signalToAtom( + signal: WritableSignal, + injector: Injector, +): Atom { + const atom: Atom = () => { + return signal() + } + atom.set = (value) => + // @ts-expect-error Fix + typeof value === 'function' ? signal.update(value) : signal.set(value) + atom.get = () => signal() + atom.subscribe = (observer) => { + return toObservable(computed(signal), { injector }).subscribe(observer) + } + return atom +} + +function angularReactivity(injector: Injector): TableReactivityBindings { + return { + createReadonlyAtom: (fn: () => T, options?: TableAtomOptions) => { + return computedToReadonlyAtom( + computed(() => fn(), { + equal: options?.compare, + debugName: options?.debugName, + }), + injector, + ) + }, + createWritableAtom: ( + value: T, + options?: TableAtomOptions, + ): Atom => { + return signalToAtom( + signal(value, { + equal: options?.compare, + debugName: options?.debugName, + }), + injector, + ) + }, + untrack: untracked, + } +} diff --git a/packages/angular-table/src/lazySignalInitializer.ts b/packages/angular-table/src/lazySignalInitializer.ts index 92f8dcc901..45c1fc0e9a 100644 --- a/packages/angular-table/src/lazySignalInitializer.ts +++ b/packages/angular-table/src/lazySignalInitializer.ts @@ -1,4 +1,4 @@ -import { untracked } from '@angular/core' +import { effect, untracked } from '@angular/core' /** * Implementation from @tanstack/angular-query diff --git a/packages/angular-table/tests/flex-render/flex-render-table.test.ts b/packages/angular-table/tests/flex-render/flex-render-table.test.ts index 8a9a71cd12..c3111e43f9 100644 --- a/packages/angular-table/tests/flex-render/flex-render-table.test.ts +++ b/packages/angular-table/tests/flex-render/flex-render-table.test.ts @@ -535,9 +535,6 @@ export function createTestTable( return { ...(optionsFn?.() ?? {}), _features: stockFeatures, - _rowModels: { - coreRowModel: createCoreRowModel(), - }, columns: this.columns(), data: this.data(), } as TableOptions diff --git a/packages/angular-table/tests/injectTable.test.ts b/packages/angular-table/tests/injectTable.test.ts index 0173cf4d23..8de7e0b51f 100644 --- a/packages/angular-table/tests/injectTable.test.ts +++ b/packages/angular-table/tests/injectTable.test.ts @@ -121,7 +121,10 @@ describe('injectTable', () => { TestBed.tick() - expect(coreRowModelFn).toHaveBeenCalledOnce() + // TODO: pagination state update twice during first table construct + // optionsStore is a signal -> so if updated with state in queuemicrotask will trigger twice + expect(coreRowModelFn).toHaveBeenCalledTimes(2) + expect(coreRowModelFn.mock.calls[0]![0].rows.length).toEqual(10) expect(coreRowModelFn.mock.calls[0]![0].rows.length).toEqual(10) expect(rowModelFn).toHaveBeenCalledTimes(2) diff --git a/packages/angular-table/vite.config.ts b/packages/angular-table/vite.config.ts index f1dc9d9158..cf80dec4c6 100644 --- a/packages/angular-table/vite.config.ts +++ b/packages/angular-table/vite.config.ts @@ -5,7 +5,7 @@ import packageJson from './package.json' const tsconfigPath = path.join(import.meta.dirname, 'tsconfig.test.json') const testDirPath = path.join(import.meta.dirname, 'tests') -const angularPlugin = angular({ tsconfig: tsconfigPath, jit: true }) +const angularPlugin = angular({ tsconfig: tsconfigPath }) export default defineConfig({ plugins: [angularPlugin], diff --git a/packages/table-core/src/core/table/constructTable.ts b/packages/table-core/src/core/table/constructTable.ts index de0eca24bc..f5fac0a3b2 100644 --- a/packages/table-core/src/core/table/constructTable.ts +++ b/packages/table-core/src/core/table/constructTable.ts @@ -1,6 +1,10 @@ -import { createAtom, createStore } from '@tanstack/store' +import { createAtom } from '@tanstack/store' import { coreFeatures } from '../coreFeatures' import { cloneState } from '../../utils' +import { + atomToStore, + readonlyAtomToStore, +} from '../../features/table-reactivity/tableReactivityFeature' import type { RowData } from '../../types/type-utils' import type { TableFeature, TableFeatures } from '../../types/TableFeatures' import type { Table, Table_Internal } from '../../types/Table' @@ -21,12 +25,19 @@ export function constructTable< TFeatures extends TableFeatures, TData extends RowData, >(tableOptions: TableOptions): Table { + const signals = tableOptions.reactivity ?? { + createWritableAtom: createAtom, + createReadonlyAtom: createAtom, + untrack: (fn) => fn(), + } + const table = { + _reactivity: signals, _features: { ...coreFeatures, ...tableOptions._features }, _rowModels: {}, _rowModelFns: {}, get options() { - return this.optionsStore.state + return this.optionsStore.get() }, set options(value) { this.optionsStore.setState(() => value) @@ -41,10 +52,15 @@ export function constructTable< return Object.assign(obj, feature.getDefaultTableOptions?.(table)) }, {}) as TableOptions - table.optionsStore = createStore({ - ...defaultOptions, - ...tableOptions, - }) + table.optionsStore = atomToStore( + signals.createWritableAtom( + { + ...defaultOptions, + ...tableOptions, + }, + { debugName: 'table/optionsStore' }, + ), + ) table.initialState = getInitialTableState( table._features, @@ -57,31 +73,41 @@ export function constructTable< for (const key of stateKeys) { // create writable base atom - table.baseAtoms[key] = createAtom(table.initialState[key]) as any + table.baseAtoms[key] = signals.createWritableAtom(table.initialState[key], { + debugName: `table/baseAtoms/${key}`, + }) as any // create readonly derived atom: on each get(), read current options (state, then external atom, then base) - ;(table.atoms as any)[key] = createAtom(() => { - // Reading optionsStore.state keeps this reactive to setOptions - const opts = table.optionsStore.state - const state = opts.state - if (key in (state ?? {})) { - return state![key] - } - const externalAtom = opts.atoms?.[key] - if (externalAtom) { - return externalAtom.get() - } - return table.baseAtoms[key].get() - }) + ;(table.atoms as any)[key] = signals.createReadonlyAtom( + () => { + // Reading optionsStore.state keeps this reactive to setOptions + const opts = table.optionsStore.state + const state = opts.state + if (key in (state ?? {})) { + return state![key] + } + const externalAtom = opts.atoms?.[key] + if (externalAtom) { + return externalAtom.get() + } + return table.baseAtoms[key].get() + }, + { debugName: `table/atoms/${key}` }, + ) } - table.store = createStore(() => { - const snapshot = {} as TableState - for (const key of stateKeys) { - snapshot[key] = table.atoms[key].get() - } - return snapshot - }) + table.store = readonlyAtomToStore( + signals.createReadonlyAtom( + () => { + const snapshot = {} as TableState + for (const key of stateKeys) { + snapshot[key] = table.atoms[key].get() + } + return snapshot + }, + { debugName: 'table/store' }, + ), + ) if ( process.env.NODE_ENV === 'development' && diff --git a/packages/table-core/src/core/table/coreTablesFeature.types.ts b/packages/table-core/src/core/table/coreTablesFeature.types.ts index 62fde58013..748794c3ba 100644 --- a/packages/table-core/src/core/table/coreTablesFeature.types.ts +++ b/packages/table-core/src/core/table/coreTablesFeature.types.ts @@ -5,6 +5,7 @@ import type { RowData, Updater } from '../../types/type-utils' import type { TableFeatures } from '../../types/TableFeatures' import type { CachedRowModels, CreateRowModels_All } from '../../types/RowModel' import type { TableOptions } from '../../types/TableOptions' +import type { TableReactivityBindings } from '../../features/table-reactivity/tableReactivityFeature' import type { TableState, TableState_All } from '../../types/TableState' export interface TableMeta< @@ -108,12 +109,20 @@ export interface TableOptions_Table< * Pass in individual self-managed state to the table. */ state?: Partial> + /** + * Table custom reactibity bindings. + */ + readonly reactivity?: TableReactivityBindings } export interface Table_CoreProperties< TFeatures extends TableFeatures, TData extends RowData, > { + /** + * Table custom reactivity bindings. + */ + _reactivity: TableReactivityBindings /** * The features that are enabled for the table. */ diff --git a/packages/table-core/src/features/table-reactivity/tableReactivityFeature.ts b/packages/table-core/src/features/table-reactivity/tableReactivityFeature.ts index aa236a3b68..023b8fa35f 100644 --- a/packages/table-core/src/features/table-reactivity/tableReactivityFeature.ts +++ b/packages/table-core/src/features/table-reactivity/tableReactivityFeature.ts @@ -1,5 +1,6 @@ -import type { ReadonlyStore, Store } from '@tanstack/store' -import type { TableFeature, TableFeatures } from '../../types/TableFeatures' +import { ReadonlyStore, Store } from '@tanstack/store' +import type { Atom, AtomOptions, ReadonlyAtom } from '@tanstack/store' +import type { TableFeatures } from '../../types/TableFeatures' import type { RowData } from '../../types/type-utils' interface TableReactivityFeatureConstructors< @@ -7,76 +8,36 @@ interface TableReactivityFeatureConstructors< TData extends RowData, > {} -export function constructReactivityFeature< - TFeatures extends TableFeatures, - TData extends RowData, ->(bindings: { - stateNotifier?: () => unknown - optionsNotifier?: () => unknown -}): TableFeature> { - return { - constructTableAPIs: (table) => { - table.optionsStore = bindStore( - table.optionsStore, - bindings.optionsNotifier, - ) - table.atoms = bindAtoms(table.atoms, bindings.stateNotifier) - }, - } +export interface TableAtomOptions extends AtomOptions { + debugName: string } -const bindStore = | ReadonlyStore>( - store: T, - notifier?: () => unknown, -): T => { - const stateDescriptor = Object.getOwnPropertyDescriptor( - Object.getPrototypeOf(store), - 'state', - )! - - Object.defineProperty(store, 'state', { - configurable: true, - enumerable: true, - get() { - notifier?.() - return stateDescriptor.get!.call(store) - }, - }) +export interface TableReactivityBindings { + createWritableAtom: ( + initialValue: T, + options?: TableAtomOptions, + ) => Atom + createReadonlyAtom: ( + fn: () => T, + options?: TableAtomOptions, + ) => ReadonlyAtom + untrack: (fn: () => T) => T +} +export function atomToStore(atom: Atom): Store { + // TODO: just reuse store class, fix type issue this is just a fast workaround + const store = new Store({} as T) + store['atom'] = atom return store } -// Wraps an atoms/baseAtoms map so that `.get()` on any individual atom -// calls the framework notifier first — matching how `bindStore` wraps -// `store.state`. The proxy also transparently forwards missing slices -// (atoms for features not registered on this table) as `undefined`. -const bindAtoms = (atoms: T, notifier?: () => unknown): T => { - if (!notifier) return atoms - // Cache wrapped atoms so referential identity is stable per slice. - const wrappedCache = new Map() - return new Proxy(atoms, { - get(target, prop, receiver) { - const atom = Reflect.get(target, prop, receiver) as unknown - if (!atom || typeof prop !== 'string' || !isAtomLike(atom)) { - return atom - } - if (wrappedCache.has(prop)) return wrappedCache.get(prop) - const originalGet = atom.get.bind(atom) - const wrapped = new Proxy(atom, { - get(atomTarget, atomProp, atomReceiver) { - if (atomProp === 'get') { - return () => { - notifier() - return originalGet() - } - } - return Reflect.get(atomTarget, atomProp, atomReceiver) - }, - }) - wrappedCache.set(prop, wrapped) - return wrapped - }, - }) +export function readonlyAtomToStore( + atom: ReadonlyAtom, +): ReadonlyStore { + // TODO: just reuse store class, fix type issue this is just a fast workaround + const store = new ReadonlyStore({} as T) + store['atom'] = atom + return store } interface AtomLike { From 5a5be8b4601dff1e2998880550ad511695234713 Mon Sep 17 00:00:00 2001 From: riccardoperra Date: Mon, 27 Apr 2026 20:06:10 +0200 Subject: [PATCH 2/5] feat(table): update reactivity bindings --- packages/angular-table/src/injectTable.ts | 78 +------------------ packages/angular-table/src/signals.ts | 64 +++++++++++++++ .../tests/angularReactivityFeature.test.ts | 36 +++------ packages/react-table/src/useTable.ts | 10 +-- packages/solid-table/src/createTable.ts | 31 ++------ packages/solid-table/src/signals.ts | 66 ++++++++++++++++ packages/table-core/package.json | 4 + .../src/core/table/constructTable.ts | 42 +++++----- .../src/core/table/coreTablesFeature.types.ts | 4 +- .../src/core/table/coreTablesFeature.utils.ts | 2 +- .../table-reactivity/table-reactivity.ts | 39 ++++++++++ .../tableReactivityFeature.ts | 53 ------------- .../table-reactivity/tanstack-signals.ts | 18 +++++ packages/table-core/src/index.ts | 2 +- packages/table-core/src/types/Table.ts | 4 +- packages/table-core/tsdown.config.ts | 1 + 16 files changed, 241 insertions(+), 213 deletions(-) create mode 100644 packages/angular-table/src/signals.ts create mode 100644 packages/solid-table/src/signals.ts create mode 100644 packages/table-core/src/features/table-reactivity/table-reactivity.ts delete mode 100644 packages/table-core/src/features/table-reactivity/tableReactivityFeature.ts create mode 100644 packages/table-core/src/features/table-reactivity/tanstack-signals.ts diff --git a/packages/angular-table/src/injectTable.ts b/packages/angular-table/src/injectTable.ts index d3d72b921a..811a262106 100644 --- a/packages/angular-table/src/injectTable.ts +++ b/packages/angular-table/src/injectTable.ts @@ -4,24 +4,20 @@ import { computed, effect, inject, - signal, untracked, } from '@angular/core' import { constructTable } from '@tanstack/table-core' -import { toObservable } from '@angular/core/rxjs-interop' -import { shallow } from '@tanstack/angular-store' import { lazyInit } from './lazySignalInitializer' +import { angularReactivity } from './signals' import type { Atom, ReadonlyAtom } from '@tanstack/angular-store' import type { RowData, Table, - TableAtomOptions, TableFeatures, TableOptions, - TableReactivityBindings, TableState, } from '@tanstack/table-core' -import type { Signal, ValueEqualityFn, WritableSignal } from '@angular/core' +import type { Signal, ValueEqualityFn } from '@angular/core' /** * Store mode: pass `selector` (required) to project from full table state. @@ -138,16 +134,10 @@ export function injectTable< const injector = inject(Injector) return lazyInit(() => { - const resolvedOptions: TableOptions = { + const table = constructTable({ ...options(), reactivity: angularReactivity(injector), - } as TableOptions - - const table = constructTable(resolvedOptions) as AngularTable< - TFeatures, - TData, - TSelected - > + }) as AngularTable let isMount = true effect( @@ -194,63 +184,3 @@ export function injectTable< return table }) } - -function computedToReadonlyAtom( - signal: () => T, - injector: Injector, -): ReadonlyAtom { - const atom: ReadonlyAtom = computed(() => - signal(), - ) as unknown as ReadonlyAtom - atom.get = () => signal() - atom.subscribe = (observer) => { - return toObservable(computed(signal), { - injector: injector, - }).subscribe(observer) - } - return atom -} - -function signalToAtom( - signal: WritableSignal, - injector: Injector, -): Atom { - const atom: Atom = () => { - return signal() - } - atom.set = (value) => - // @ts-expect-error Fix - typeof value === 'function' ? signal.update(value) : signal.set(value) - atom.get = () => signal() - atom.subscribe = (observer) => { - return toObservable(computed(signal), { injector }).subscribe(observer) - } - return atom -} - -function angularReactivity(injector: Injector): TableReactivityBindings { - return { - createReadonlyAtom: (fn: () => T, options?: TableAtomOptions) => { - return computedToReadonlyAtom( - computed(() => fn(), { - equal: options?.compare, - debugName: options?.debugName, - }), - injector, - ) - }, - createWritableAtom: ( - value: T, - options?: TableAtomOptions, - ): Atom => { - return signalToAtom( - signal(value, { - equal: options?.compare, - debugName: options?.debugName, - }), - injector, - ) - }, - untrack: untracked, - } -} diff --git a/packages/angular-table/src/signals.ts b/packages/angular-table/src/signals.ts new file mode 100644 index 0000000000..1e3cff44e3 --- /dev/null +++ b/packages/angular-table/src/signals.ts @@ -0,0 +1,64 @@ +import { computed, signal, untracked } from '@angular/core' +import { toObservable } from '@angular/core/rxjs-interop' +import type { + TableAtomOptions, + TableReactivityBindings, +} from '@tanstack/table-core' +import type { Injector, Signal, WritableSignal } from '@angular/core' +import type { Atom, Observer, ReadonlyAtom } from '@tanstack/angular-store' + +function signalToReadonlyAtom( + signal: Signal, + injector: Injector, +): ReadonlyAtom { + return Object.assign(signal, { + get: () => signal(), + subscribe: (observer: Observer) => { + return toObservable(computed(signal), { injector: injector }).subscribe( + observer, + ) + }, + }) +} + +function signalToWritableAtom( + signal: WritableSignal, + injector: Injector, +): Atom { + return Object.assign(signal.asReadonly(), { + set: (updater: T | ((prevVal: T) => T)) => { + typeof updater === 'function' + ? signal.update(updater as (val: T) => T) + : signal.set(updater) + }, + get: () => signal(), + subscribe: (observer: Observer) => { + return toObservable(computed(signal), { injector: injector }).subscribe( + observer, + ) + }, + }) +} + +export function angularReactivity(injector: Injector): TableReactivityBindings { + return { + createReadonlyAtom: (fn: () => T, options?: TableAtomOptions) => { + const signal = computed(() => fn(), { + equal: options?.compare, + debugName: options?.debugName, + }) + return signalToReadonlyAtom(signal, injector) + }, + createWritableAtom: ( + value: T, + options?: TableAtomOptions, + ): Atom => { + const writableSignal = signal(value, { + equal: options?.compare, + debugName: options?.debugName, + }) + return signalToWritableAtom(writableSignal, injector) + }, + untrack: untracked, + } +} diff --git a/packages/angular-table/tests/angularReactivityFeature.test.ts b/packages/angular-table/tests/angularReactivityFeature.test.ts index 461c0addd2..35f381d716 100644 --- a/packages/angular-table/tests/angularReactivityFeature.test.ts +++ b/packages/angular-table/tests/angularReactivityFeature.test.ts @@ -30,12 +30,6 @@ describe('angularReactivityFeature', () => { _features: { ...stockFeatures }, columns: columns, getRowId: (row) => row.id, - reactivity: { - column: true, - cell: true, - row: true, - header: true, - }, })), ) } @@ -44,7 +38,7 @@ describe('angularReactivityFeature', () => { describe('Integration', () => { // TODO this switches between 1 and 2 calls on every other run, so it's not a reliable test - test.skip('methods within effect will be re-trigger when options/state changes', () => { + test('methods within effect will be re-trigger when options/state changes', () => { const data = signal>([{ id: '1', title: 'Title' }]) const table = createTestTable(data) const isSelectedRow1Captor = vi.fn<(val: boolean) => void>() @@ -86,35 +80,23 @@ describe('angularReactivityFeature', () => { TestBed.tick() expect(isSelectedRow1Captor).toHaveBeenCalledTimes(2) expect(cellGetValueCaptor).toHaveBeenCalledTimes(1) - expect(columnIsVisibleCaptor).toHaveBeenCalledTimes(2) + expect(columnIsVisibleCaptor).toHaveBeenCalledTimes(1) data.set([{ id: '1', title: 'Title 3' }]) TestBed.tick() - // Row/cell instances are memoized by id in the atoms-based table, so a - // data change that preserves ids does not emit a new cell reference. - // `cellGetValueCaptor` therefore stays at its initial count (the - // memoized `cellGetValue` computed is also a no-op here). Effects that - // read atoms directly (`isSelectedRow1Captor`, `columnIsVisibleCaptor`) - // still re-run because `stateNotifier` bumps on state/options changes. expect(isSelectedRow1Captor).toHaveBeenCalledTimes(3) - expect(cellGetValueCaptor).toHaveBeenCalledTimes(1) - expect(columnIsVisibleCaptor).toHaveBeenCalledTimes(3) + expect(cellGetValueCaptor).toHaveBeenCalledTimes(2) + expect(columnIsVisibleCaptor).toHaveBeenCalledTimes(2) cell().column.toggleVisibility(false) TestBed.tick() - expect(isSelectedRow1Captor).toHaveBeenCalledTimes(4) - expect(cellGetValueCaptor).toHaveBeenCalledTimes(1) - expect(columnIsVisibleCaptor).toHaveBeenCalledTimes(4) + expect(isSelectedRow1Captor).toHaveBeenCalledTimes(3) + expect(cellGetValueCaptor).toHaveBeenCalledTimes(2) + expect(columnIsVisibleCaptor).toHaveBeenCalledTimes(3) - expect(isSelectedRow1Captor.mock.calls).toEqual([ - [false], - [true], - [true], - [true], - ]) - expect(cellGetValueCaptor.mock.calls).toEqual([['1']]) + expect(isSelectedRow1Captor.mock.calls).toEqual([[false], [true], [true]]) + expect(cellGetValueCaptor.mock.calls).toEqual([['1'], ['1']]) expect(columnIsVisibleCaptor.mock.calls).toEqual([ - [true], [true], [true], [false], diff --git a/packages/react-table/src/useTable.ts b/packages/react-table/src/useTable.ts index 4fe97567f9..a83b015fe4 100644 --- a/packages/react-table/src/useTable.ts +++ b/packages/react-table/src/useTable.ts @@ -2,6 +2,7 @@ import { useMemo, useState } from 'react' import { constructTable } from '@tanstack/table-core' +import { tanstackSignals } from '@tanstack/table-core/features/table-reactivity/tanstack-signals' import { shallow, useSelector } from '@tanstack/react-store' import { FlexRender } from './FlexRender' import { Subscribe } from './Subscribe' @@ -121,11 +122,10 @@ export function useTable< ({}) as TSelected, ): ReactTable { const [table] = useState(() => { - const tableInstance = constructTable(tableOptions) as ReactTable< - TFeatures, - TData, - TSelected - > + const tableInstance = constructTable({ + ...tableOptions, + reactivity: tableOptions.reactivity ?? tanstackSignals(), + }) as ReactTable tableInstance.Subscribe = ((props: any) => { return Subscribe({ diff --git a/packages/solid-table/src/createTable.ts b/packages/solid-table/src/createTable.ts index 1017399540..23bf923319 100644 --- a/packages/solid-table/src/createTable.ts +++ b/packages/solid-table/src/createTable.ts @@ -1,9 +1,7 @@ -import { - constructReactivityFeature, - constructTable, -} from '@tanstack/table-core' -import { createComputed, createSignal, mergeProps, untrack } from 'solid-js' +import { constructTable } from '@tanstack/table-core' +import { createComputed, getOwner, mergeProps, untrack } from 'solid-js' import { shallow, useSelector } from '@tanstack/solid-store' +import { solidReactivity } from './signals' import type { Atom, ReadonlyAtom } from '@tanstack/solid-store' import type { Accessor, JSX } from 'solid-js' import type { @@ -11,6 +9,7 @@ import type { Table, TableFeatures, TableOptions, + TableReactivityBindings, TableState, } from '@tanstack/table-core' @@ -61,17 +60,10 @@ export function createTable< selector: (state: TableState) => TSelected = () => ({}) as TSelected, ): SolidTable { - const [notifier, setNotifier] = createSignal(void 0, { equals: false }) - - const solidReactivityFeature = constructReactivityFeature({ - stateNotifier: () => notifier(), - optionsNotifier: () => notifier(), - }) + const owner = getOwner()! const mergedOptions = mergeProps(tableOptions, { - _features: mergeProps(tableOptions._features, { - solidReactivityFeature, - }), + reactivity: solidReactivity(owner), }) as any const resolvedOptions = mergeProps( @@ -84,7 +76,7 @@ export function createTable< }, }, mergedOptions, - ) as TableOptions + ) as TableOptions & { reactivity: TableReactivityBindings } const table = constructTable(resolvedOptions) as SolidTable< TFeatures, @@ -92,9 +84,6 @@ export function createTable< TSelected > - const allState = useSelector(table.store) - const allOptions = useSelector(table.optionsStore) - createComputed(() => { const userState = tableOptions.state if (userState) { @@ -110,12 +99,6 @@ export function createTable< }) }) - createComputed(() => { - allState() - allOptions() - untrack(() => setNotifier(void 0)) - }) - table.Subscribe = ((props: { source?: Atom | ReadonlyAtom selector?: ((state: unknown) => unknown) | undefined diff --git a/packages/solid-table/src/signals.ts b/packages/solid-table/src/signals.ts new file mode 100644 index 0000000000..2abde786d9 --- /dev/null +++ b/packages/solid-table/src/signals.ts @@ -0,0 +1,66 @@ +import { + createMemo, + createSignal, + observable, + runWithOwner, + untrack, +} from 'solid-js' +import type { Accessor, Owner, Setter } from 'solid-js' +import type { + TableAtomOptions, + TableReactivityBindings, +} from '@tanstack/table-core' +import type { Atom, Observer, ReadonlyAtom } from '@tanstack/solid-store' + +function signalToReadonlyAtom( + signal: Accessor, + owner: Owner, +): ReadonlyAtom { + return Object.assign(signal, { + get: () => signal(), + subscribe: (observer: Observer) => { + return runWithOwner(owner, () => observable(signal))!.subscribe(observer) + }, + }) +} + +function signalToWritableAtom( + signalTuple: [Accessor, Setter], + owner: Owner, +): Atom { + const [signal, setSignal] = signalTuple + return Object.assign(signal, { + set: (updater: T | ((prevVal: T) => T)) => { + typeof updater === 'function' + ? setSignal(updater as unknown as (prev: T) => T) + : setSignal(updater as Exclude) + }, + get: () => signal(), + subscribe: (observer: Observer) => { + return runWithOwner(owner, () => observable(signal))!.subscribe(observer) + }, + }) +} + +export function solidReactivity(owner: Owner): TableReactivityBindings { + return { + createReadonlyAtom: (fn: () => T, options?: TableAtomOptions) => { + const signal = createMemo(() => fn(), { + equals: options?.compare, + name: options?.debugName, + }) + return signalToReadonlyAtom(signal, owner) + }, + createWritableAtom: ( + value: T, + options?: TableAtomOptions, + ): Atom => { + const writableSignal = createSignal(value, { + equals: options?.compare, + name: options?.debugName, + }) + return signalToWritableAtom(writableSignal, owner) + }, + untrack: untrack, + } +} diff --git a/packages/table-core/package.json b/packages/table-core/package.json index b600b906e7..e217fa9135 100644 --- a/packages/table-core/package.json +++ b/packages/table-core/package.json @@ -34,6 +34,10 @@ "import": "./dist/index.js", "require": "./dist/index.cjs" }, + "./features/table-reactivity/tanstack-signals": { + "import": "./dist/features/table-reactivity/tanstack-signals.js", + "require": "./dist/features/table-reactivity/tanstack-signals.cjs" + }, "./flex-render": { "import": "./dist/flex-render.js", "require": "./dist/flex-render.cjs" diff --git a/packages/table-core/src/core/table/constructTable.ts b/packages/table-core/src/core/table/constructTable.ts index f5fac0a3b2..b9df1de96d 100644 --- a/packages/table-core/src/core/table/constructTable.ts +++ b/packages/table-core/src/core/table/constructTable.ts @@ -1,10 +1,7 @@ -import { createAtom } from '@tanstack/store' import { coreFeatures } from '../coreFeatures' import { cloneState } from '../../utils' -import { - atomToStore, - readonlyAtomToStore, -} from '../../features/table-reactivity/tableReactivityFeature' +import { atomToStore } from '../../features/table-reactivity/table-reactivity' +import type { TableReactivityBindings } from '../../features/table-reactivity/table-reactivity' import type { RowData } from '../../types/type-utils' import type { TableFeature, TableFeatures } from '../../types/TableFeatures' import type { Table, Table_Internal } from '../../types/Table' @@ -21,15 +18,18 @@ export function getInitialTableState( return cloneState(initialState) as TableState } +export type ConstructTable< + TFeatures extends TableFeatures, + TData extends RowData, +> = TableOptions & { + reactivity: TableReactivityBindings +} + export function constructTable< TFeatures extends TableFeatures, TData extends RowData, ->(tableOptions: TableOptions): Table { - const signals = tableOptions.reactivity ?? { - createWritableAtom: createAtom, - createReadonlyAtom: createAtom, - untrack: (fn) => fn(), - } +>(tableOptions: ConstructTable): Table { + const signals = tableOptions.reactivity const table = { _reactivity: signals, @@ -40,7 +40,7 @@ export function constructTable< return this.optionsStore.get() }, set options(value) { - this.optionsStore.setState(() => value) + this.optionsStore.set(() => value) }, baseAtoms: {}, atoms: {}, @@ -52,15 +52,9 @@ export function constructTable< return Object.assign(obj, feature.getDefaultTableOptions?.(table)) }, {}) as TableOptions - table.optionsStore = atomToStore( - signals.createWritableAtom( - { - ...defaultOptions, - ...tableOptions, - }, - { debugName: 'table/optionsStore' }, - ), - ) + table.optionsStore = signals.createWritableAtom< + TableOptions + >({ ...defaultOptions, ...tableOptions }, { debugName: 'table/optionsStore' }) table.initialState = getInitialTableState( table._features, @@ -80,8 +74,8 @@ export function constructTable< // create readonly derived atom: on each get(), read current options (state, then external atom, then base) ;(table.atoms as any)[key] = signals.createReadonlyAtom( () => { - // Reading optionsStore.state keeps this reactive to setOptions - const opts = table.optionsStore.state + // Reading optionsStore.get() keeps this reactive to setOptions + const opts = table.optionsStore.get() const state = opts.state if (key in (state ?? {})) { return state![key] @@ -96,7 +90,7 @@ export function constructTable< ) } - table.store = readonlyAtomToStore( + table.store = atomToStore( signals.createReadonlyAtom( () => { const snapshot = {} as TableState diff --git a/packages/table-core/src/core/table/coreTablesFeature.types.ts b/packages/table-core/src/core/table/coreTablesFeature.types.ts index 748794c3ba..39527e62ef 100644 --- a/packages/table-core/src/core/table/coreTablesFeature.types.ts +++ b/packages/table-core/src/core/table/coreTablesFeature.types.ts @@ -5,7 +5,7 @@ import type { RowData, Updater } from '../../types/type-utils' import type { TableFeatures } from '../../types/TableFeatures' import type { CachedRowModels, CreateRowModels_All } from '../../types/RowModel' import type { TableOptions } from '../../types/TableOptions' -import type { TableReactivityBindings } from '../../features/table-reactivity/tableReactivityFeature' +import type { TableReactivityBindings } from '../../features/table-reactivity/table-reactivity' import type { TableState, TableState_All } from '../../types/TableState' export interface TableMeta< @@ -165,7 +165,7 @@ export interface Table_CoreProperties< /** * The base store for the table options. */ - optionsStore: Store> + optionsStore: Atom> /** * This is the resolved initial state of the table. */ diff --git a/packages/table-core/src/core/table/coreTablesFeature.utils.ts b/packages/table-core/src/core/table/coreTablesFeature.utils.ts index 800d08afec..65af56f5e2 100644 --- a/packages/table-core/src/core/table/coreTablesFeature.utils.ts +++ b/packages/table-core/src/core/table/coreTablesFeature.utils.ts @@ -43,5 +43,5 @@ export function table_setOptions< ): void { const newOptions = functionalUpdate(updater, table.options) const mergedOptions = table_mergeOptions(table, newOptions) - table.optionsStore.setState(() => mergedOptions) + table.optionsStore.set(() => mergedOptions) } diff --git a/packages/table-core/src/features/table-reactivity/table-reactivity.ts b/packages/table-core/src/features/table-reactivity/table-reactivity.ts new file mode 100644 index 0000000000..4af3832c06 --- /dev/null +++ b/packages/table-core/src/features/table-reactivity/table-reactivity.ts @@ -0,0 +1,39 @@ +import type { + Atom, + AtomOptions, + ReadonlyAtom, + ReadonlyStore, + Store, +} from '@tanstack/store' + +export interface TableAtomOptions extends AtomOptions { + debugName: string +} + +export interface TableReactivityBindings { + createWritableAtom: ( + initialValue: T, + options?: TableAtomOptions, + ) => Atom + createReadonlyAtom: ( + fn: () => T, + options?: TableAtomOptions, + ) => ReadonlyAtom + untrack: (fn: () => T) => T +} + +export function atomToStore(atom: Atom): Store +export function atomToStore(atom: ReadonlyAtom): ReadonlyStore +export function atomToStore( + atom: Atom | ReadonlyAtom, +): Store | ReadonlyStore { + const store: Store = Object.assign(atom, { + get state() { + return atom.get() + }, + }) as Store + if ('set' in atom) { + store.setState = atom.set.bind(atom) + } + return store +} diff --git a/packages/table-core/src/features/table-reactivity/tableReactivityFeature.ts b/packages/table-core/src/features/table-reactivity/tableReactivityFeature.ts deleted file mode 100644 index 023b8fa35f..0000000000 --- a/packages/table-core/src/features/table-reactivity/tableReactivityFeature.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { ReadonlyStore, Store } from '@tanstack/store' -import type { Atom, AtomOptions, ReadonlyAtom } from '@tanstack/store' -import type { TableFeatures } from '../../types/TableFeatures' -import type { RowData } from '../../types/type-utils' - -interface TableReactivityFeatureConstructors< - TFeatures extends TableFeatures, - TData extends RowData, -> {} - -export interface TableAtomOptions extends AtomOptions { - debugName: string -} - -export interface TableReactivityBindings { - createWritableAtom: ( - initialValue: T, - options?: TableAtomOptions, - ) => Atom - createReadonlyAtom: ( - fn: () => T, - options?: TableAtomOptions, - ) => ReadonlyAtom - untrack: (fn: () => T) => T -} - -export function atomToStore(atom: Atom): Store { - // TODO: just reuse store class, fix type issue this is just a fast workaround - const store = new Store({} as T) - store['atom'] = atom - return store -} - -export function readonlyAtomToStore( - atom: ReadonlyAtom, -): ReadonlyStore { - // TODO: just reuse store class, fix type issue this is just a fast workaround - const store = new ReadonlyStore({} as T) - store['atom'] = atom - return store -} - -interface AtomLike { - get: () => unknown -} - -function isAtomLike(value: unknown): value is AtomLike { - return ( - typeof value === 'object' && - value !== null && - typeof (value as { get?: unknown }).get === 'function' - ) -} diff --git a/packages/table-core/src/features/table-reactivity/tanstack-signals.ts b/packages/table-core/src/features/table-reactivity/tanstack-signals.ts new file mode 100644 index 0000000000..6cfaacee60 --- /dev/null +++ b/packages/table-core/src/features/table-reactivity/tanstack-signals.ts @@ -0,0 +1,18 @@ +import { createAtom } from '@tanstack/store' +import type { TableReactivityBindings } from './table-reactivity' + +export function tanstackSignals(): TableReactivityBindings { + return { + untrack: (fn) => fn(), + createReadonlyAtom: (fn, options) => { + return createAtom(() => fn(), { + compare: options?.compare, + }) + }, + createWritableAtom: (value, options) => { + return createAtom(value, { + compare: options?.compare, + }) + }, + } +} diff --git a/packages/table-core/src/index.ts b/packages/table-core/src/index.ts index 3ed19019b4..5b3d05ccc0 100755 --- a/packages/table-core/src/index.ts +++ b/packages/table-core/src/index.ts @@ -72,7 +72,7 @@ export * from './fns/sortFns' export * from './features/stockFeatures' // tableReactivityFeature -export * from './features/table-reactivity/tableReactivityFeature' +export * from './features/table-reactivity/table-reactivity' // columnFacetingFeature export * from './features/column-faceting/columnFacetingFeature' diff --git a/packages/table-core/src/types/Table.ts b/packages/table-core/src/types/Table.ts index 6c27d25a17..afa214e90c 100644 --- a/packages/table-core/src/types/Table.ts +++ b/packages/table-core/src/types/Table.ts @@ -1,4 +1,4 @@ -import type { ReadonlyStore } from '@tanstack/store' +import type { ReadonlyAtom, ReadonlyStore } from '@tanstack/store' import type { Table_ColumnFaceting } from '../features/column-faceting/columnFacetingFeature.types' import type { Table_ColumnResizing } from '../features/column-resizing/columnResizingFeature.types' import type { Table_ColumnFiltering } from '../features/column-filtering/columnFilteringFeature.types' @@ -128,5 +128,5 @@ export type Table_Internal< initialState: TableState_All baseAtoms: BaseAtoms_All atoms: Atoms_All - store: ReadonlyStore + store: ReadonlyAtom } diff --git a/packages/table-core/tsdown.config.ts b/packages/table-core/tsdown.config.ts index c305d9b999..533e215e93 100644 --- a/packages/table-core/tsdown.config.ts +++ b/packages/table-core/tsdown.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ './src/index.ts', './src/static-functions.ts', './src/flex-render.ts', + './src/features/table-reactivity/tanstack-signals', ], format: ['esm', 'cjs'], unbundle: true, From e69b86d5c77e49441a3fc658a89a6edb1e6baab8 Mon Sep 17 00:00:00 2001 From: riccardoperra Date: Tue, 28 Apr 2026 19:47:56 +0200 Subject: [PATCH 3/5] feat(table): add abstract `batch` fn to reactivity system --- packages/angular-table/src/signals.ts | 4 +++- packages/solid-table/src/signals.ts | 2 ++ .../table-core/src/core/table/coreTablesFeature.utils.ts | 3 +-- .../src/features/table-reactivity/table-reactivity.ts | 8 +++++--- .../src/features/table-reactivity/tanstack-signals.ts | 3 ++- packages/table-core/src/types/Table.ts | 2 +- 6 files changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/angular-table/src/signals.ts b/packages/angular-table/src/signals.ts index 1e3cff44e3..1b074434e2 100644 --- a/packages/angular-table/src/signals.ts +++ b/packages/angular-table/src/signals.ts @@ -1,11 +1,12 @@ import { computed, signal, untracked } from '@angular/core' import { toObservable } from '@angular/core/rxjs-interop' +import { batch } from '@tanstack/angular-store' +import type { Atom, Observer, ReadonlyAtom } from '@tanstack/angular-store' import type { TableAtomOptions, TableReactivityBindings, } from '@tanstack/table-core' import type { Injector, Signal, WritableSignal } from '@angular/core' -import type { Atom, Observer, ReadonlyAtom } from '@tanstack/angular-store' function signalToReadonlyAtom( signal: Signal, @@ -60,5 +61,6 @@ export function angularReactivity(injector: Injector): TableReactivityBindings { return signalToWritableAtom(writableSignal, injector) }, untrack: untracked, + batch: (fn) => fn(), } } diff --git a/packages/solid-table/src/signals.ts b/packages/solid-table/src/signals.ts index 2abde786d9..473bef5842 100644 --- a/packages/solid-table/src/signals.ts +++ b/packages/solid-table/src/signals.ts @@ -1,4 +1,5 @@ import { + batch, createMemo, createSignal, observable, @@ -62,5 +63,6 @@ export function solidReactivity(owner: Owner): TableReactivityBindings { return signalToWritableAtom(writableSignal, owner) }, untrack: untrack, + batch: batch, } } diff --git a/packages/table-core/src/core/table/coreTablesFeature.utils.ts b/packages/table-core/src/core/table/coreTablesFeature.utils.ts index 65af56f5e2..01872f17ed 100644 --- a/packages/table-core/src/core/table/coreTablesFeature.utils.ts +++ b/packages/table-core/src/core/table/coreTablesFeature.utils.ts @@ -1,4 +1,3 @@ -import { batch } from '@tanstack/store' import { cloneState, functionalUpdate } from '../../utils' import type { RowData, Updater } from '../../types/type-utils' import type { TableFeatures } from '../../types/TableFeatures' @@ -10,7 +9,7 @@ export function table_reset< TData extends RowData, >(table: Table_Internal): void { const snap = cloneState(table.initialState) - batch(() => { + table._reactivity.batch(() => { for (const key of Object.keys(snap) as Array) { ;(table.baseAtoms as any)[key].set(snap[key] as any) } diff --git a/packages/table-core/src/features/table-reactivity/table-reactivity.ts b/packages/table-core/src/features/table-reactivity/table-reactivity.ts index 4af3832c06..21fc33b65e 100644 --- a/packages/table-core/src/features/table-reactivity/table-reactivity.ts +++ b/packages/table-core/src/features/table-reactivity/table-reactivity.ts @@ -20,6 +20,7 @@ export interface TableReactivityBindings { options?: TableAtomOptions, ) => ReadonlyAtom untrack: (fn: () => T) => T + batch: (fn: () => void) => void } export function atomToStore(atom: Atom): Store @@ -27,11 +28,12 @@ export function atomToStore(atom: ReadonlyAtom): ReadonlyStore export function atomToStore( atom: Atom | ReadonlyAtom, ): Store | ReadonlyStore { - const store: Store = Object.assign(atom, { - get state() { + const store: Store = atom as Store + Object.defineProperty(atom, 'state', { + get() { return atom.get() }, - }) as Store + }) if ('set' in atom) { store.setState = atom.set.bind(atom) } diff --git a/packages/table-core/src/features/table-reactivity/tanstack-signals.ts b/packages/table-core/src/features/table-reactivity/tanstack-signals.ts index 6cfaacee60..d043568068 100644 --- a/packages/table-core/src/features/table-reactivity/tanstack-signals.ts +++ b/packages/table-core/src/features/table-reactivity/tanstack-signals.ts @@ -1,8 +1,9 @@ -import { createAtom } from '@tanstack/store' +import { batch, createAtom } from '@tanstack/store' import type { TableReactivityBindings } from './table-reactivity' export function tanstackSignals(): TableReactivityBindings { return { + batch: batch, untrack: (fn) => fn(), createReadonlyAtom: (fn, options) => { return createAtom(() => fn(), { diff --git a/packages/table-core/src/types/Table.ts b/packages/table-core/src/types/Table.ts index afa214e90c..e2be6d1d29 100644 --- a/packages/table-core/src/types/Table.ts +++ b/packages/table-core/src/types/Table.ts @@ -128,5 +128,5 @@ export type Table_Internal< initialState: TableState_All baseAtoms: BaseAtoms_All atoms: Atoms_All - store: ReadonlyAtom + store: ReadonlyStore } From 05c95dc5a2cc388fd88573db85531968fb1068bb Mon Sep 17 00:00:00 2001 From: riccardoperra Date: Tue, 28 Apr 2026 20:31:26 +0200 Subject: [PATCH 4/5] feat(table): refactor vue, svelte, lit, preact adapters to support native reactivity --- packages/angular-table/package.json | 1 - .../tests/benchmarks/injectTable.benchmark.ts | 35 ---------- .../angular-table/tests/benchmarks/setup.ts | 60 ----------------- packages/lit-table/src/TableController.ts | 23 ++----- .../tests/unit/defaultReactivity.test.ts | 22 +++++++ packages/preact-table/package.json | 1 + packages/preact-table/src/signals.ts | 62 ++++++++++++++++++ packages/preact-table/src/useTable.ts | 10 +-- .../preact-table/tests/unit/signals.test.ts | 20 ++++++ .../svelte-table/src/createTable.svelte.ts | 64 +++++-------------- packages/svelte-table/src/signals.svelte.ts | 64 +++++++++++++++++++ .../src/core/table/constructTable.ts | 2 +- .../tests/helpers/generateTestTable.ts | 3 + .../tests/helpers/rowPinningHelpers.ts | 4 +- .../row-pinning/rowPinningFeature.test.ts | 3 + .../row-selection/rowSelectionFeature.test.ts | 14 ++++ .../columnGroupingFeature.test.ts | 2 + .../unit/core/columns/constructColumn.test.ts | 2 + .../unit/core/table/constructTable.test.ts | 2 + .../table/stockFeaturesInitialState.test.ts | 2 + .../tests/unit/core/tableAtoms.test.ts | 9 ++- packages/vue-table/src/signals.ts | 61 ++++++++++++++++++ packages/vue-table/src/useTable.ts | 32 ++-------- packages/vue-table/tests/unit/signals.test.ts | 22 +++++++ pnpm-lock.yaml | 18 ++++++ 25 files changed, 343 insertions(+), 195 deletions(-) delete mode 100644 packages/angular-table/tests/benchmarks/injectTable.benchmark.ts delete mode 100644 packages/angular-table/tests/benchmarks/setup.ts create mode 100644 packages/lit-table/tests/unit/defaultReactivity.test.ts create mode 100644 packages/preact-table/src/signals.ts create mode 100644 packages/preact-table/tests/unit/signals.test.ts create mode 100644 packages/svelte-table/src/signals.svelte.ts create mode 100644 packages/vue-table/src/signals.ts create mode 100644 packages/vue-table/tests/unit/signals.test.ts diff --git a/packages/angular-table/package.json b/packages/angular-table/package.json index 9f6d6b301c..92eb2f9ca7 100644 --- a/packages/angular-table/package.json +++ b/packages/angular-table/package.json @@ -55,7 +55,6 @@ "test:build": "publint --strict", "test:eslint": "eslint ./src", "test:lib": "vitest", - "test:benchmark": "vitest bench", "test:lib:dev": "vitest --watch", "test:types": "tsc && vitest --typecheck" }, diff --git a/packages/angular-table/tests/benchmarks/injectTable.benchmark.ts b/packages/angular-table/tests/benchmarks/injectTable.benchmark.ts deleted file mode 100644 index 78e24cd7d4..0000000000 --- a/packages/angular-table/tests/benchmarks/injectTable.benchmark.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { setTimeout } from 'node:timers/promises' -import { bench, describe } from 'vitest' -import { benchCases, columns, createTestTable, dataMap } from './setup' - -const nIteration = 5 - -for (const benchCase of benchCases) { - describe(`injectTable - ${benchCase.size} elements`, () => { - const data = dataMap[benchCase.size]! - - bench( - `No reactivity`, - async () => { - const table = createTestTable(false, data, columns) - await setTimeout(0) - table.getRowModel() - }, - { - iterations: nIteration, - }, - ) - - bench( - `Full reactivity`, - async () => { - const table = createTestTable(true, data, columns) - await setTimeout(0) - table.getRowModel() - }, - { - iterations: nIteration, - }, - ) - }) -} diff --git a/packages/angular-table/tests/benchmarks/setup.ts b/packages/angular-table/tests/benchmarks/setup.ts deleted file mode 100644 index 00fbe06dc4..0000000000 --- a/packages/angular-table/tests/benchmarks/setup.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { injectTable, stockFeatures } from '../../src' -import type { ColumnDef } from '../../src' - -export function createData(size: number) { - return Array.from({ length: size }, (_, index) => ({ - id: index, - title: `title-${index}`, - name: `name-${index}`, - })) -} - -export const columns: Array> = [ - { id: 'col1' }, - { id: 'col2' }, - { id: 'col3' }, - { id: 'col4' }, - { id: 'col5' }, - { id: 'col6' }, - { id: 'col7' }, -] - -export function createTestTable( - enableGranularReactivity: boolean, - data: Array, - columns: Array, -) { - return injectTable(() => ({ - _features: stockFeatures, - columns: columns, - data, - reactivity: { - table: enableGranularReactivity, - row: enableGranularReactivity, - column: enableGranularReactivity, - cell: enableGranularReactivity, - header: enableGranularReactivity, - }, - })) -} - -export const benchCases = [ - { size: 100, max: 5, threshold: 10 }, - { size: 1000, max: 25, threshold: 50 }, - { size: 2000, max: 50, threshold: 100 }, - { size: 5000, max: 100, threshold: 500 }, - { size: 10_000, max: 200, threshold: 1000 }, - { size: 25_000, max: 500, threshold: 1000 }, - { size: 50_000, max: 1500, threshold: 1000 }, - { size: 100_000, max: 2000, threshold: 1500 }, -] - -console.log('Seeding data...') - -export const dataMap = {} as Record> - -for (const benchCase of benchCases) { - dataMap[benchCase.size] = createData(benchCase.size) -} - -console.log('Seed data completed') diff --git a/packages/lit-table/src/TableController.ts b/packages/lit-table/src/TableController.ts index a8cbc84870..9941866cc0 100644 --- a/packages/lit-table/src/TableController.ts +++ b/packages/lit-table/src/TableController.ts @@ -1,7 +1,5 @@ -import { - constructReactivityFeature, - constructTable, -} from '@tanstack/table-core' +import { constructTable } from '@tanstack/table-core' +import { tanstackSignals } from '@tanstack/table-core/features/table-reactivity/tanstack-signals' import { FlexRender } from './flexRender' import type { Atom, ReadonlyAtom } from '@tanstack/store' import type { @@ -10,6 +8,7 @@ import type { Table, TableFeatures, TableOptions, + TableReactivityBindings, TableState, } from '@tanstack/table-core' import type { @@ -146,19 +145,11 @@ export class TableController< ({}) as TSelected, ): LitTable { if (!this._table) { - const litReactivityFeature = constructReactivityFeature( - { - stateNotifier: () => this._notifier, - optionsNotifier: () => this._notifier, - }, - ) - - const mergedOptions: TableOptions = { + const mergedOptions: TableOptions & { + reactivity: TableReactivityBindings + } = { ...tableOptions, - _features: { - ...tableOptions._features, - litReactivityFeature, - }, + reactivity: tableOptions.reactivity ?? tanstackSignals(), mergeOptions: ( defaultOptions: TableOptions, newOptions: Partial>, diff --git a/packages/lit-table/tests/unit/defaultReactivity.test.ts b/packages/lit-table/tests/unit/defaultReactivity.test.ts new file mode 100644 index 0000000000..9d8e572681 --- /dev/null +++ b/packages/lit-table/tests/unit/defaultReactivity.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from 'vitest' +import { TableController } from '../../src/TableController' + +describe('TableController', () => { + test('uses default reactivity when constructing a table', () => { + const host = { + addController: () => {}, + requestUpdate: () => {}, + } + const controller = new TableController(host) + + const table = controller.table({ + _features: {}, + _rowModels: {}, + columns: [], + data: [], + }) + + expect(table._reactivity).toBeDefined() + expect(table.store.get()).toEqual({}) + }) +}) diff --git a/packages/preact-table/package.json b/packages/preact-table/package.json index 4963839875..19f501e364 100644 --- a/packages/preact-table/package.json +++ b/packages/preact-table/package.json @@ -57,6 +57,7 @@ "build": "tsdown" }, "dependencies": { + "@preact/signals": "^2.5.1", "@tanstack/preact-store": "^0.13.0", "@tanstack/table-core": "workspace:*" }, diff --git a/packages/preact-table/src/signals.ts b/packages/preact-table/src/signals.ts new file mode 100644 index 0000000000..eac4dcf904 --- /dev/null +++ b/packages/preact-table/src/signals.ts @@ -0,0 +1,62 @@ +import { batch, computed, signal, untracked } from '@preact/signals' +import type { + TableAtomOptions, + TableReactivityBindings, +} from '@tanstack/table-core' +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 39e4edcc7a..13fef1430f 100644 --- a/packages/preact-table/src/useTable.ts +++ b/packages/preact-table/src/useTable.ts @@ -3,6 +3,7 @@ import { constructTable } from '@tanstack/table-core' import { shallow, useSelector } from '@tanstack/preact-store' import { FlexRender } from './FlexRender' import { Subscribe } from './Subscribe' +import { preactReactivity } from './signals' import type { CellData, RowData, @@ -92,11 +93,10 @@ export function useTable< ({}) as TSelected, ): PreactTable { const [table] = useState(() => { - const tableInstance = constructTable(tableOptions) as PreactTable< - TFeatures, - TData, - TSelected - > + const tableInstance = constructTable({ + ...tableOptions, + reactivity: tableOptions.reactivity ?? preactReactivity(), + }) as PreactTable tableInstance.Subscribe = ((props: any) => { return Subscribe({ diff --git a/packages/preact-table/tests/unit/signals.test.ts b/packages/preact-table/tests/unit/signals.test.ts new file mode 100644 index 0000000000..3f4efa04e7 --- /dev/null +++ b/packages/preact-table/tests/unit/signals.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from 'vitest' +import { preactReactivity } from '../../src/signals' + +describe('preactReactivity', () => { + test('creates writable and readonly atoms from Preact signals', () => { + const reactivity = preactReactivity() + const count = reactivity.createWritableAtom(1, { debugName: 'count' }) + const doubled = reactivity.createReadonlyAtom(() => count.get() * 2, { + debugName: 'doubled', + }) + + expect(count.get()).toBe(1) + expect(doubled.get()).toBe(2) + + count.set((value) => value + 1) + + expect(count.get()).toBe(2) + expect(doubled.get()).toBe(4) + }) +}) diff --git a/packages/svelte-table/src/createTable.svelte.ts b/packages/svelte-table/src/createTable.svelte.ts index 0171065618..81a3be43fc 100644 --- a/packages/svelte-table/src/createTable.svelte.ts +++ b/packages/svelte-table/src/createTable.svelte.ts @@ -1,15 +1,14 @@ -import { - constructReactivityFeature, - constructTable, -} from '@tanstack/table-core' -import { shallow, useSelector } from '@tanstack/svelte-store' +import { constructTable } from '@tanstack/table-core' +import { useSelector } from '@tanstack/svelte-store' import { untrack } from 'svelte' import { mergeObjects } from './merge-objects' +import { svelteReactivity } from './signals.svelte' import type { RowData, Table, TableFeatures, TableOptions, + TableReactivityBindings, TableState, } from '@tanstack/table-core' @@ -38,23 +37,12 @@ export function createTable< selector: (state: TableState) => TSelected = () => ({}) as TSelected, ): SvelteTable { - // 1. Create $state-based notifier for reactivity feature - let notifierValue = $state(0) - - // 2. Construct reactivity feature (same pattern as solid/vue/angular) - const svelteReactivityFeature = constructReactivityFeature({ - stateNotifier: () => notifierValue, - optionsNotifier: () => notifierValue, - }) - - // 3. Merge reactivity feature into options using mergeObjects (preserves getters) + // 1. Merge reactivity into options using mergeObjects (preserves getters) const mergedOptions = mergeObjects(tableOptions, { - _features: mergeObjects(tableOptions._features, { - svelteReactivityFeature, - }), - }) as any + reactivity: tableOptions.reactivity ?? svelteReactivity(), + }) as TableOptions & { reactivity: TableReactivityBindings } - // 4. Set up resolved options with mergeOptions handler + // 2. Set up resolved options with mergeOptions handler const resolvedOptions = mergeObjects( { mergeOptions: ( @@ -65,42 +53,25 @@ export function createTable< }, }, mergedOptions, - ) as TableOptions + ) as TableOptions & { reactivity: TableReactivityBindings } - // 5. Construct table + // 3. Construct table const table = constructTable(resolvedOptions) as SvelteTable< TFeatures, TData, TSelected > - // 6. Subscribe to all state and options via useSelector - const allState = useSelector(table.store, (state) => state) - const allOptions = useSelector(table.optionsStore, (options) => options) - - // 7. Sync store changes -> notifier. - // Use $effect.pre so this runs before DOM updates (like Solid's createComputed). - // Use untrack for the write so the effect only depends on allState/allOptions, - // not on notifierValue itself (which would cause an infinite loop). - $effect.pre(() => { - allState.current - allOptions.current - untrack(() => { - notifierValue++ - }) - }) - - // 8. Sync options reactively. When controlled state changes (e.g., $state + // 4. Sync options reactively. When controlled state changes (e.g., $state // inside createTableState), the effect re-runs and calls setOptions. // Use $effect.pre so the table sees updated options BEFORE the DOM renders, // ensuring getRowModel() returns current data (not stale, one-frame-behind data). // The reactive reads (state getters, data getter) happen OUTSIDE untrack - // so they become dependencies. The setOptions call is INSIDE untrack to - // prevent tracking notifierValue (read via store.state's stateNotifier - // interceptor inside setOptions), which would cause an infinite loop. + // so they become dependencies. The setOptions call is INSIDE untrack so + // option writes do not subscribe this effect to table internals. $effect.pre(() => { // Read reactive getters to create $effect dependencies on external state - const state = mergedOptions.state + const state: Record | undefined = mergedOptions.state if (state) { for (const key in state) { void state[key] @@ -110,15 +81,12 @@ export function createTable< untrack(() => { table.setOptions((prev) => { - return mergeObjects(prev, mergedOptions) as TableOptions< - TFeatures, - TData - > + return mergeObjects(prev, mergedOptions) }) }) }) - // 9. State selector + // 5. State selector const stateStore = useSelector(table.store, selector) Object.defineProperty(table, 'state', { diff --git a/packages/svelte-table/src/signals.svelte.ts b/packages/svelte-table/src/signals.svelte.ts new file mode 100644 index 0000000000..de06ef472a --- /dev/null +++ b/packages/svelte-table/src/signals.svelte.ts @@ -0,0 +1,64 @@ +import { flushSync, untrack } from 'svelte' +import type { + TableAtomOptions, + TableReactivityBindings, +} from '@tanstack/table-core' +import type { Atom, Observer, ReadonlyAtom } from '@tanstack/svelte-store' + +function observerToCallback( + observerOrNext: Observer | ((value: T) => void), +): (value: T) => void { + return typeof observerOrNext === 'function' + ? observerOrNext + : (value) => observerOrNext.next?.(value) +} + +function subscribeToRune( + getValue: () => T, + observerOrNext: Observer | ((value: T) => void), +) { + const callback = observerToCallback(observerOrNext) + const unsubscribe = $effect.root(() => { + $effect(() => { + callback(getValue()) + }) + }) + + return { unsubscribe } +} + +export function svelteReactivity(): TableReactivityBindings { + return { + createReadonlyAtom: (fn: () => T, _options?: TableAtomOptions) => { + const value = $derived.by(fn) + + return { + get: () => value, + subscribe: ((observerOrNext: Observer | ((value: T) => void)) => { + return subscribeToRune(() => value, observerOrNext) + }) as ReadonlyAtom['subscribe'], + } + }, + createWritableAtom: ( + initialValue: T, + _options?: TableAtomOptions, + ): Atom => { + let value = $state(initialValue) + + return { + set: (updater: T | ((prevVal: T) => T)) => { + value = + typeof updater === 'function' + ? (updater as (prevVal: T) => T)(value) + : updater + }, + get: () => value, + subscribe: ((observerOrNext: Observer | ((value: T) => void)) => { + return subscribeToRune(() => value, observerOrNext) + }) as Atom['subscribe'], + } + }, + untrack: untrack, + batch: (fn) => flushSync(fn), + } +} diff --git a/packages/table-core/src/core/table/constructTable.ts b/packages/table-core/src/core/table/constructTable.ts index b9df1de96d..eb9a5125d6 100644 --- a/packages/table-core/src/core/table/constructTable.ts +++ b/packages/table-core/src/core/table/constructTable.ts @@ -21,7 +21,7 @@ export function getInitialTableState( export type ConstructTable< TFeatures extends TableFeatures, TData extends RowData, -> = TableOptions & { +> = Omit, 'reactivity'> & { reactivity: TableReactivityBindings } diff --git a/packages/table-core/tests/helpers/generateTestTable.ts b/packages/table-core/tests/helpers/generateTestTable.ts index c89144871e..3ebb3860b4 100644 --- a/packages/table-core/tests/helpers/generateTestTable.ts +++ b/packages/table-core/tests/helpers/generateTestTable.ts @@ -1,6 +1,7 @@ import { constructTable, coreFeatures } from '../../src' import { generateTestColumnDefs } from '../fixtures/data/generateTestColumnDefs' import { generateTestData } from '../fixtures/data/generateTestData' +import { tanstackSignals } from '../../src/features/table-reactivity/tanstack-signals' import type { Row, Table, @@ -26,6 +27,7 @@ export function generateTestTableWithData( return constructTable({ data, columns, + reactivity: tanstackSignals(), getSubRows: (row: Row) => row.subRows, ...options, _features: { @@ -43,6 +45,7 @@ export function generateTestTableFromData( return constructTable({ data, columns, + reactivity: tanstackSignals(), ...options, _features: { ...coreFeatures, diff --git a/packages/table-core/tests/helpers/rowPinningHelpers.ts b/packages/table-core/tests/helpers/rowPinningHelpers.ts index a74b25dcc9..4f13ed7660 100644 --- a/packages/table-core/tests/helpers/rowPinningHelpers.ts +++ b/packages/table-core/tests/helpers/rowPinningHelpers.ts @@ -2,11 +2,12 @@ import { vi } from 'vitest' import { getDefaultRowPinningState } from '../../src/features/row-pinning/rowPinningFeature.utils' import { constructTable, - createColumnHelper, coreFeatures, + createColumnHelper, rowPinningFeature, } from '../../src' import { generateTestData } from '../fixtures/data/generateTestData' +import { tanstackSignals } from '../../src/features/table-reactivity/tanstack-signals' import { generateTestTableWithData } from './generateTestTable' import type { ColumnDef, RowPinningState, TableOptions } from '../../src' import type { Person } from '../fixtures/data/types' @@ -77,6 +78,7 @@ export function createRowPinningTable( const table = constructTable({ _features, _rowModels: {}, + reactivity: tanstackSignals(), data, columns, getSubRows: (row: any) => row.subRows, diff --git a/packages/table-core/tests/implementation/features/row-pinning/rowPinningFeature.test.ts b/packages/table-core/tests/implementation/features/row-pinning/rowPinningFeature.test.ts index eee05a687c..b688e272d7 100644 --- a/packages/table-core/tests/implementation/features/row-pinning/rowPinningFeature.test.ts +++ b/packages/table-core/tests/implementation/features/row-pinning/rowPinningFeature.test.ts @@ -12,6 +12,7 @@ import { createTableWithMockOnPinningChange, } from '../../../helpers/rowPinningHelpers' import { generateTestData } from '../../../fixtures/data/generateTestData' +import { tanstackSignals } from '../../../../src/features/table-reactivity/tanstack-signals' import type { ColumnDef, Row } from '../../../../src' import type { Person } from '../../../fixtures/data/types' @@ -176,6 +177,7 @@ describe('table methods', () => { _rowModels: { paginatedRowModel: createPaginatedRowModel(), }, + reactivity: tanstackSignals(), data, columns, getSubRows: (row) => row.subRows, @@ -215,6 +217,7 @@ describe('table methods', () => { _rowModels: { paginatedRowModel: createPaginatedRowModel(), }, + reactivity: tanstackSignals(), data, columns, getSubRows: (row) => row.subRows, diff --git a/packages/table-core/tests/implementation/features/row-selection/rowSelectionFeature.test.ts b/packages/table-core/tests/implementation/features/row-selection/rowSelectionFeature.test.ts index 65a510eb19..6634941df8 100644 --- a/packages/table-core/tests/implementation/features/row-selection/rowSelectionFeature.test.ts +++ b/packages/table-core/tests/implementation/features/row-selection/rowSelectionFeature.test.ts @@ -6,6 +6,7 @@ import { } from '../../../../src' import * as RowSelectionUtils from '../../../../src/features/row-selection/rowSelectionFeature.utils' import { generateTestData } from '../../../fixtures/data/generateTestData' +import { tanstackSignals } from '../../../../src/features/table-reactivity/tanstack-signals' import type { Person } from '../../../fixtures/data/types' import type { ColumnDef } from '../../../../src' @@ -41,6 +42,7 @@ describe('rowSelectionFeature', () => { const table = constructTable({ _features, _rowModels: {}, + reactivity: tanstackSignals(), enableRowSelection: true, renderFallbackValue: '', data, @@ -70,6 +72,7 @@ describe('rowSelectionFeature', () => { const table = constructTable({ _features, _rowModels: {}, + reactivity: tanstackSignals(), enableRowSelection: true, renderFallbackValue: '', data, @@ -99,6 +102,7 @@ describe('rowSelectionFeature', () => { const table = constructTable({ _features, _rowModels: {}, + reactivity: tanstackSignals(), enableRowSelection: true, renderFallbackValue: '', data, @@ -125,6 +129,7 @@ describe('rowSelectionFeature', () => { const table = constructTable({ _features, _rowModels: {}, + reactivity: tanstackSignals(), enableRowSelection: true, renderFallbackValue: '', data, @@ -150,6 +155,7 @@ describe('rowSelectionFeature', () => { const table = constructTable({ _features, _rowModels: {}, + reactivity: tanstackSignals(), enableRowSelection: true, renderFallbackValue: '', data, @@ -175,6 +181,7 @@ describe('rowSelectionFeature', () => { const table = constructTable({ _features, _rowModels: {}, + reactivity: tanstackSignals(), enableRowSelection: true, renderFallbackValue: '', data, @@ -200,6 +207,7 @@ describe('rowSelectionFeature', () => { const table = constructTable({ _features, _rowModels: {}, + reactivity: tanstackSignals(), enableRowSelection: true, renderFallbackValue: '', data, @@ -221,6 +229,7 @@ describe('rowSelectionFeature', () => { const table = constructTable({ _features, _rowModels: {}, + reactivity: tanstackSignals(), enableRowSelection: true, renderFallbackValue: '', data, @@ -242,6 +251,7 @@ describe('rowSelectionFeature', () => { const table = constructTable({ _features, _rowModels: {}, + reactivity: tanstackSignals(), enableRowSelection: true, renderFallbackValue: '', data, @@ -266,6 +276,7 @@ describe('rowSelectionFeature', () => { const table = constructTable({ _features, _rowModels: {}, + reactivity: tanstackSignals(), enableRowSelection: true, renderFallbackValue: '', data, @@ -292,6 +303,7 @@ describe('rowSelectionFeature', () => { const table = constructTable({ _features, _rowModels: {}, + reactivity: tanstackSignals(), enableRowSelection: true, renderFallbackValue: '', data, @@ -318,6 +330,7 @@ describe('rowSelectionFeature', () => { const table = constructTable({ _features, _rowModels: {}, + reactivity: tanstackSignals(), enableRowSelection: (row) => row.index === 0, // only first row is selectable (of 2 sub-rows) renderFallbackValue: '', data, @@ -343,6 +356,7 @@ describe('rowSelectionFeature', () => { const table = constructTable({ _features, _rowModels: {}, + reactivity: tanstackSignals(), enableRowSelection: true, renderFallbackValue: '', data, diff --git a/packages/table-core/tests/performance/features/column-grouping/columnGroupingFeature.test.ts b/packages/table-core/tests/performance/features/column-grouping/columnGroupingFeature.test.ts index 89c45cc525..1f374786f8 100644 --- a/packages/table-core/tests/performance/features/column-grouping/columnGroupingFeature.test.ts +++ b/packages/table-core/tests/performance/features/column-grouping/columnGroupingFeature.test.ts @@ -8,6 +8,7 @@ import { } from '../../../../src' import { createColumnHelper } from '../../../../src/helpers/columnHelper' import { generateTestData } from '../../../fixtures/data/generateTestData' +import { tanstackSignals } from '../../../../src/features/table-reactivity/tanstack-signals' import type { Person } from '../../../fixtures/data/types' import type { ColumnDef } from '../../../../src' @@ -46,6 +47,7 @@ describe('#getGroupedRowModel', () => { _rowModels: { groupedRowModel: createGroupedRowModel(aggregationFns), }, + reactivity: tanstackSignals(), onStateChange() {}, renderFallbackValue: '', data, diff --git a/packages/table-core/tests/unit/core/columns/constructColumn.test.ts b/packages/table-core/tests/unit/core/columns/constructColumn.test.ts index 13f1e190cd..063f192bff 100644 --- a/packages/table-core/tests/unit/core/columns/constructColumn.test.ts +++ b/packages/table-core/tests/unit/core/columns/constructColumn.test.ts @@ -2,12 +2,14 @@ import { describe, expect, it } from 'vitest' import { coreColumnsFeature } from '../../../../src/core/columns/coreColumnsFeature' import { constructColumn } from '../../../../src/core/columns/constructColumn' import { constructTable } from '../../../../src' +import { tanstackSignals } from '../../../../src/features/table-reactivity/tanstack-signals' import type { ColumnDef } from '../../../../src/types/ColumnDef' describe('constructColumn', () => { it('should create a column with all core column APIs and properties', () => { const table = constructTable({ _features: { coreColumnsFeature }, + reactivity: tanstackSignals(), columns: [] as Array, data: [] as Array, }) 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 0846fa18bd..da10f2048e 100644 --- a/packages/table-core/tests/unit/core/table/constructTable.test.ts +++ b/packages/table-core/tests/unit/core/table/constructTable.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { constructTable, coreFeatures } from '../../../../src' +import { tanstackSignals } from '../../../../src/features/table-reactivity/tanstack-signals' describe('constructTable', () => { it('should create a table with all core table APIs and properties', () => { @@ -7,6 +8,7 @@ describe('constructTable', () => { _features: { ...coreFeatures, }, + reactivity: tanstackSignals(), columns: [], data: [], }) diff --git a/packages/table-core/tests/unit/core/table/stockFeaturesInitialState.test.ts b/packages/table-core/tests/unit/core/table/stockFeaturesInitialState.test.ts index 88d66abfd6..3af707bdb9 100644 --- a/packages/table-core/tests/unit/core/table/stockFeaturesInitialState.test.ts +++ b/packages/table-core/tests/unit/core/table/stockFeaturesInitialState.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from 'vitest' import { constructTable, stockFeatures } from '../../../../src' +import { tanstackSignals } from '../../../../src/features/table-reactivity/tanstack-signals' describe('constructTable with stockFeatures', () => { it('should include all feature states in initial state', () => { const table = constructTable({ _features: stockFeatures, + reactivity: tanstackSignals(), columns: [], data: [], }) diff --git a/packages/table-core/tests/unit/core/tableAtoms.test.ts b/packages/table-core/tests/unit/core/tableAtoms.test.ts index e3c3312135..77699be9d2 100644 --- a/packages/table-core/tests/unit/core/tableAtoms.test.ts +++ b/packages/table-core/tests/unit/core/tableAtoms.test.ts @@ -6,8 +6,12 @@ import { rowSelectionFeature, rowSortingFeature, } from '../../../src' -import type { Table_Internal } from '../../../src' -import type { PaginationState, SortingState } from '../../../src' +import { tanstackSignals } from '../../../src/features/table-reactivity/tanstack-signals' +import type { + PaginationState, + SortingState, + Table_Internal, +} from '../../../src' const _features = { rowPaginationFeature, @@ -19,6 +23,7 @@ function makeTable(options: any = {}) { return constructTable({ _features, _rowModels: {}, + reactivity: tanstackSignals(), columns: [], data: [], ...options, diff --git a/packages/vue-table/src/signals.ts b/packages/vue-table/src/signals.ts new file mode 100644 index 0000000000..8b25caa53e --- /dev/null +++ b/packages/vue-table/src/signals.ts @@ -0,0 +1,61 @@ +import { computed, shallowRef, watch } from 'vue' +import type { + TableAtomOptions, + TableReactivityBindings, +} from '@tanstack/table-core' +import type { Atom, Observer, ReadonlyAtom } from '@tanstack/vue-store' +import type { ComputedRef, ShallowRef } from 'vue' + +function observerToCallback( + observerOrNext: Observer | ((value: T) => void), +): (value: T) => void { + return typeof observerOrNext === 'function' + ? observerOrNext + : (value) => observerOrNext.next?.(value) +} + +function refToReadonlyAtom(source: ComputedRef | ShallowRef): ReadonlyAtom { + return Object.assign(source, { + get: () => source.value, + subscribe: ((observerOrNext: Observer | ((value: T) => void)) => { + const stop = watch(source, observerToCallback(observerOrNext), { + flush: 'sync', + }) + return { unsubscribe: stop } + }) as ReadonlyAtom['subscribe'], + }) +} + +function refToWritableAtom(source: ShallowRef): 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 stop = watch(source, observerToCallback(observerOrNext), { + flush: 'sync', + }) + return { unsubscribe: stop } + }) as Atom['subscribe'], + }) +} + +export function vueReactivity(): TableReactivityBindings { + return { + createReadonlyAtom: (fn: () => T, _options?: TableAtomOptions) => { + return refToReadonlyAtom(computed(fn)) + }, + createWritableAtom: ( + value: T, + _options?: TableAtomOptions, + ): Atom => { + return refToWritableAtom(shallowRef(value) as ShallowRef) + }, + untrack: (fn) => fn(), + batch: (fn) => fn(), + } +} diff --git a/packages/vue-table/src/useTable.ts b/packages/vue-table/src/useTable.ts index 4cb84d7d94..3ff7822c10 100644 --- a/packages/vue-table/src/useTable.ts +++ b/packages/vue-table/src/useTable.ts @@ -1,10 +1,8 @@ -import { isRef, ref, unref, watch, watchEffect } from 'vue' -import { - constructReactivityFeature, - constructTable, -} from '@tanstack/table-core' +import { unref, watch } from 'vue' +import { constructTable } from '@tanstack/table-core' import { shallow, useSelector } from '@tanstack/vue-store' import { mergeProxy } from './merge-proxy' +import { vueReactivity } from './signals' import type { Atom, ReadonlyAtom } from '@tanstack/vue-store' import type { NoInfer, @@ -12,6 +10,7 @@ import type { Table, TableFeatures, TableOptions, + TableReactivityBindings, TableState, } from '@tanstack/table-core' import type { MaybeRef, VNode } from 'vue' @@ -106,8 +105,6 @@ export function useTable< selector: (state: TableState) => TSelected = () => ({}) as TSelected, ): VueTable { - const notifier = ref(0) - const syncTableOptions = ( table: Table, options: TableOptionsWithReactiveData, @@ -118,17 +115,9 @@ export function useTable< ) } - const vueReactivityFeature = constructReactivityFeature({ - stateNotifier: () => notifier.value, - optionsNotifier: () => notifier.value, - }) - const mergedOptions = { ...tableOptions, - _features: { - ...tableOptions._features, - vueReactivityFeature, - }, + reactivity: tableOptions.reactivity ?? vueReactivity(), } const resolvedOptions = mergeProxy( @@ -144,7 +133,7 @@ export function useTable< return mergeProxy(defaultOptions, newOptions) }, }, - ) as TableOptions + ) as TableOptions & { reactivity: TableReactivityBindings } const table = constructTable(resolvedOptions) as VueTable< TFeatures, @@ -152,15 +141,6 @@ export function useTable< TSelected > - const allState = useSelector(table.store, (state) => state) - const allOptions = useSelector(table.optionsStore, (state) => state) - - watchEffect(() => { - allState.value - allOptions.value - notifier.value++ - }) - watch( () => getReactiveOptionDeps( diff --git a/packages/vue-table/tests/unit/signals.test.ts b/packages/vue-table/tests/unit/signals.test.ts new file mode 100644 index 0000000000..49b8ddde34 --- /dev/null +++ b/packages/vue-table/tests/unit/signals.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from 'vitest' +import { nextTick } from 'vue' +import { vueReactivity } from '../../src/signals' + +describe('vueReactivity', () => { + test('creates writable and readonly atoms from Vue refs', async () => { + const reactivity = vueReactivity() + const count = reactivity.createWritableAtom(1, { debugName: 'count' }) + const doubled = reactivity.createReadonlyAtom(() => count.get() * 2, { + debugName: 'doubled', + }) + + expect(count.get()).toBe(1) + expect(doubled.get()).toBe(2) + + count.set((value) => value + 1) + await nextTick() + + expect(count.get()).toBe(2) + expect(doubled.get()).toBe(4) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 644514e09d..7599acd404 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6606,6 +6606,9 @@ importers: packages/preact-table: dependencies: + '@preact/signals': + specifier: ^2.5.1 + version: 2.9.0(preact@10.29.1) '@tanstack/preact-store': specifier: ^0.13.0 version: 0.13.0(preact@10.29.1) @@ -9463,6 +9466,14 @@ packages: '@babel/core': 7.x vite: 2.x || 3.x || 4.x || 5.x || 6.x || 7.x || 8.x + '@preact/signals-core@1.14.1': + resolution: {integrity: sha512-vxPpfXqrwUe9lpjqfYNjAF/0RF/eFGeLgdJzdmIIZjpOnTmGmAB4BjWone562mJGMRP4frU6iZ6ei3PDsu52Ng==} + + '@preact/signals@2.9.0': + resolution: {integrity: sha512-hYrY0KyUqkDgOl1qba/JGn6y81pXnurn21PMaxfcMwdncdZ3M/oVdmpTvEnsGjh48dIwDVc7bjWHqIsngSjYug==} + peerDependencies: + preact: '>= 10.25.0 || >=11.0.0-0' + '@prefresh/babel-plugin@0.5.3': resolution: {integrity: sha512-57LX2SHs4BX2s1IwCjNzTE2OJeEepRCNf1VTEpbNcUyHfMO68eeOWGDIt4ob9aYlW6PEWZ1SuwNikuoIXANDtQ==} @@ -18215,6 +18226,13 @@ snapshots: - rollup - supports-color + '@preact/signals-core@1.14.1': {} + + '@preact/signals@2.9.0(preact@10.29.1)': + dependencies: + '@preact/signals-core': 1.14.1 + preact: 10.29.1 + '@prefresh/babel-plugin@0.5.3': {} '@prefresh/core@1.5.9(preact@10.29.1)': From 5d55d049dbd18effbfece641989155637ed762dd Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:32:30 +0000 Subject: [PATCH 5/5] ci: apply automated fixes --- packages/vue-table/src/signals.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/vue-table/src/signals.ts b/packages/vue-table/src/signals.ts index 8b25caa53e..af3b09cfaa 100644 --- a/packages/vue-table/src/signals.ts +++ b/packages/vue-table/src/signals.ts @@ -14,7 +14,9 @@ function observerToCallback( : (value) => observerOrNext.next?.(value) } -function refToReadonlyAtom(source: ComputedRef | ShallowRef): ReadonlyAtom { +function refToReadonlyAtom( + source: ComputedRef | ShallowRef, +): ReadonlyAtom { return Object.assign(source, { get: () => source.value, subscribe: ((observerOrNext: Observer | ((value: T) => void)) => {