diff --git a/examples/angular/column-ordering/src/app/app.component.ts b/examples/angular/column-ordering/src/app/app.component.ts index e35177930c..e1a9fcc62d 100644 --- a/examples/angular/column-ordering/src/app/app.component.ts +++ b/examples/angular/column-ordering/src/app/app.component.ts @@ -20,11 +20,6 @@ import type { ColumnVisibilityState, } from '@tanstack/angular-table' -const _features = tableFeatures({ - columnVisibilityFeature, - columnOrderingFeature, -}) - const defaultColumns: Array> = [ { header: 'Name', @@ -77,6 +72,11 @@ const defaultColumns: Array> = [ }, ] +const _features = tableFeatures({ + columnVisibilityFeature, + columnOrderingFeature, +}) + @Component({ selector: 'app-root', standalone: true, @@ -91,12 +91,13 @@ export class AppComponent { readonly table = injectTable(() => ({ _features, - columns: defaultColumns, data: this.data(), + columns: defaultColumns, state: { columnOrder: this.columnOrder(), columnVisibility: this.columnVisibility(), }, + enableExperimentalReactivity: true, onColumnVisibilityChange: (updaterOrValue) => { typeof updaterOrValue === 'function' ? this.columnVisibility.update(updaterOrValue) diff --git a/examples/angular/column-pinning-sticky/src/app/app.component.ts b/examples/angular/column-pinning-sticky/src/app/app.component.ts index 4cc061927f..5356af2ea6 100644 --- a/examples/angular/column-pinning-sticky/src/app/app.component.ts +++ b/examples/angular/column-pinning-sticky/src/app/app.component.ts @@ -102,6 +102,7 @@ export class AppComponent { }, columns: this.columns(), data: this.data(), + enableExperimentalReactivity: true, debugTable: true, debugHeaders: true, debugColumns: true, diff --git a/examples/angular/column-pinning/src/app/app.component.ts b/examples/angular/column-pinning/src/app/app.component.ts index 6348ba8089..2b5395472d 100644 --- a/examples/angular/column-pinning/src/app/app.component.ts +++ b/examples/angular/column-pinning/src/app/app.component.ts @@ -115,6 +115,7 @@ export class AppComponent { columnOrder: this.columnOrder(), columnPinning: this.columnPinning(), }, + enableExperimentalReactivity: true, onColumnVisibilityChange: (updaterOrValue) => { typeof updaterOrValue === 'function' ? this.columnVisibility.update(updaterOrValue) diff --git a/examples/angular/grouping/src/app/app.component.ts b/examples/angular/grouping/src/app/app.component.ts index 16e0b07746..9fc842b8fe 100644 --- a/examples/angular/grouping/src/app/app.component.ts +++ b/examples/angular/grouping/src/app/app.component.ts @@ -54,6 +54,7 @@ export class AppComponent { paginatedRowModel: createPaginatedRowModel(), filteredRowModel: createFilteredRowModel(), }, + enableExperimentalReactivity: true, data: this.data(), columns, initialState: { diff --git a/examples/angular/row-selection-signal/src/app/app.component.ts b/examples/angular/row-selection-signal/src/app/app.component.ts index 3d29456c6a..274247fb9a 100644 --- a/examples/angular/row-selection-signal/src/app/app.component.ts +++ b/examples/angular/row-selection-signal/src/app/app.component.ts @@ -113,6 +113,17 @@ export class AppComponent { paginatedRowModel: createPaginatedRowModel(), }, data: this.data(), + enableExperimentalReactivity: true, + _features: { + rowSelectionFeature, + rowPaginationFeature, + columnFilteringFeature, + columnVisibilityFeature, + }, + _rowModels: { + filteredRowModel: createFilteredRowModel(), + paginatedRowModel: createPaginatedRowModel(), + }, columns: this.columns, state: { rowSelection: this.rowSelection(), diff --git a/examples/angular/row-selection/src/app/app.component.ts b/examples/angular/row-selection/src/app/app.component.ts index 61596f33a0..7705bebd16 100644 --- a/examples/angular/row-selection/src/app/app.component.ts +++ b/examples/angular/row-selection/src/app/app.component.ts @@ -123,6 +123,7 @@ export class AppComponent { state: { rowSelection: this.rowSelection(), }, + enableExperimentalReactivity: true, enableRowSelection: true, // enable row selection for all rows // enableRowSelection: row => row.original.age > 18, // or enable row selection conditionally per row onRowSelectionChange: (updaterOrValue) => { diff --git a/examples/angular/signal-input/src/app/person-table/person-table.component.ts b/examples/angular/signal-input/src/app/person-table/person-table.component.ts index 87d093f4e8..00d28904ce 100644 --- a/examples/angular/signal-input/src/app/person-table/person-table.component.ts +++ b/examples/angular/signal-input/src/app/person-table/person-table.component.ts @@ -52,6 +52,7 @@ export class PersonTableComponent { state: { pagination: this.pagination(), }, + enableExperimentalReactivity: true, onPaginationChange: (updaterOrValue) => { typeof updaterOrValue === 'function' ? this.pagination.update(updaterOrValue) diff --git a/packages/angular-table/src/flex-render.ts b/packages/angular-table/src/flex-render.ts index 70b36a829a..097b071517 100644 --- a/packages/angular-table/src/flex-render.ts +++ b/packages/angular-table/src/flex-render.ts @@ -1,19 +1,19 @@ +import type { DoCheck, OnChanges, SimpleChanges } from '@angular/core' import { ChangeDetectorRef, ComponentRef, Directive, EmbeddedViewRef, Inject, + inject, InjectionToken, Injector, Input, + isSignal, TemplateRef, Type, ViewContainerRef, - inject, - isSignal, } from '@angular/core' -import type { DoCheck, OnChanges, SimpleChanges } from '@angular/core' import type { Table } from '@tanstack/table-core' export type FlexRenderContent> = @@ -48,6 +48,8 @@ export class FlexRenderDirective> ref?: ComponentRef | EmbeddedViewRef | null = null + experimentalReactivity = false + constructor( @Inject(ViewContainerRef) private readonly viewContainerRef: ViewContainerRef, @@ -56,7 +58,7 @@ export class FlexRenderDirective> ) {} ngDoCheck(): void { - if (this.ref instanceof ComponentRef) { + if (!this.experimentalReactivity && this.ref instanceof ComponentRef) { this.ref.injector.get(ChangeDetectorRef).markForCheck() } } @@ -65,6 +67,13 @@ export class FlexRenderDirective> if (!changes['content']) { return } + + if ('table' in this.props) { + this.experimentalReactivity = + (this.props.table as Partial>).options + ?.enableExperimentalReactivity ?? false + } + this.render() } diff --git a/packages/angular-table/src/index.ts b/packages/angular-table/src/index.ts index f9bd9c806d..d84d942fa7 100644 --- a/packages/angular-table/src/index.ts +++ b/packages/angular-table/src/index.ts @@ -5,3 +5,4 @@ export * from './proxy' export * from './lazy-signal-initializer' export * from './injectTable' export * from './createTableHelper' +export * from './reactivity' diff --git a/packages/angular-table/src/injectTable.ts b/packages/angular-table/src/injectTable.ts index c71a1e8794..6b5703ee79 100644 --- a/packages/angular-table/src/injectTable.ts +++ b/packages/angular-table/src/injectTable.ts @@ -1,4 +1,12 @@ +import type { Signal } from '@angular/core' import { computed, signal } from '@angular/core' +import type { + RowData, + Table, + TableFeatures, + TableOptions, + TableState, +} from '@tanstack/table-core' import { constructTable, coreFeatures, @@ -7,14 +15,7 @@ import { } from '@tanstack/table-core' import { lazyInit } from './lazy-signal-initializer' import { proxifyTable } from './proxy' -import type { - RowData, - Table, - TableFeatures, - TableOptions, - TableState, -} from '@tanstack/table-core' -import type { Signal } from '@angular/core' +import { reactivityFeature } from './reactivity' export function injectTable< TFeatures extends TableFeatures, @@ -27,6 +28,7 @@ export function injectTable< return { ...coreFeatures, ...options()._features, + reactivityFeature, } } @@ -76,6 +78,8 @@ export function injectTable< }, ) + table._setRootNotifier?.(tableSignal as any) + // proxify Table instance to provide ability for consumer to listen to any table state changes return proxifyTable(tableSignal) }) diff --git a/packages/angular-table/src/proxy.ts b/packages/angular-table/src/proxy.ts index 04bf80f522..1e2ef1bb69 100644 --- a/packages/angular-table/src/proxy.ts +++ b/packages/angular-table/src/proxy.ts @@ -15,48 +15,46 @@ export function proxifyTable< ): Table & Signal> { const internalState = tableSignal as TableSignal + const proxyTargetImplementation = { + default: getDefaultProxyHandler(tableSignal), + experimental: getExperimentalProxyHandler(tableSignal), + } as const + return new Proxy(internalState, { apply() { - return tableSignal() + const signal = untracked(tableSignal) + const impl = signal.options.enableExperimentalReactivity + ? proxyTargetImplementation.experimental + : proxyTargetImplementation.default + return impl.apply() }, - get(target, property): any { - if (Reflect.has(target, property)) { - return Reflect.get(target, property) - } - const table = untracked(tableSignal) - /** - * Attempt to convert all accessors into computed ones, - * excluding handlers as they do not retain any reactive value - */ - if ( - typeof property === 'string' && - property.startsWith('get') && - !property.endsWith('Handler') && - !property.endsWith('Model') - ) { - const maybeFn = (table as any)[property] as Function | never - if (typeof maybeFn === 'function') { - Object.defineProperty(target, property, { - value: toComputed(tableSignal, maybeFn), - configurable: true, - enumerable: true, - }) - return (target as any)[property] - } - } - return ((target as any)[property] = (table as any)[property]) + get(target, property, receiver): any { + const signal = untracked(tableSignal) + const impl = signal.options.enableExperimentalReactivity + ? proxyTargetImplementation.experimental + : proxyTargetImplementation.default + return impl.get(target, property, receiver) }, has(_, prop) { - return Reflect.has(untracked(tableSignal), prop) + const signal = untracked(tableSignal) + const impl = signal.options.enableExperimentalReactivity + ? proxyTargetImplementation.experimental + : proxyTargetImplementation.default + return impl.has(_, prop) }, ownKeys() { - return Reflect.ownKeys(untracked(tableSignal)) + const signal = untracked(tableSignal) + const impl = signal.options.enableExperimentalReactivity + ? proxyTargetImplementation.experimental + : proxyTargetImplementation.default + return impl.ownKeys() }, getOwnPropertyDescriptor() { - return { - enumerable: true, - configurable: true, - } + const signal = untracked(tableSignal) + const impl = signal.options.enableExperimentalReactivity + ? proxyTargetImplementation.experimental + : proxyTargetImplementation.default + return impl.getOwnPropertyDescriptor() }, }) } @@ -72,29 +70,37 @@ export function proxifyTable< * we'll wrap all accessors into a cached function wrapping a computed * that return it's value based on the given parameters */ -function toComputed( - signal: Signal>, - fn: Function, -) { +export function toComputed< + TFeatures extends TableFeatures, + TData extends RowData, +>(signal: Signal>, fn: Function, debugName?: string) { const hasArgs = fn.length > 0 if (!hasArgs) { - return computed(() => { - void signal() - return fn() - }) + return computed( + () => { + void signal() + return fn() + }, + { debugName }, + ) } const computedCache: Record> = {} - return (...argsArray: Array) => { + // Declare at least a static argument in order to detect fns `length` > 0 + return (arg0: any, ...otherArgs: Array) => { + const argsArray = [arg0, ...otherArgs] const serializedArgs = serializeArgs(...argsArray) if (computedCache.hasOwnProperty(serializedArgs)) { return computedCache[serializedArgs]?.() } - const computedSignal = computed(() => { - void signal() - return fn(...argsArray) - }) + const computedSignal = computed( + () => { + void signal() + return fn(...argsArray) + }, + { debugName }, + ) computedCache[serializedArgs] = computedSignal @@ -105,3 +111,91 @@ function toComputed( function serializeArgs(...args: Array) { return JSON.stringify(args) } + +function getDefaultProxyHandler< + TFeatures extends TableFeatures, + TData extends RowData, +>(tableSignal: Signal>) { + return { + apply() { + return tableSignal() + }, + get(target, property, receiver): any { + if (Reflect.has(target, property)) { + return Reflect.get(target, property) + } + const table = untracked(tableSignal) + /** + * Attempt to convert all accessors into computed ones, + * excluding handlers as they do not retain any reactive value + */ + if ( + typeof property === 'string' && + property.startsWith('get') && + !property.endsWith('Handler') && + !property.endsWith('Model') + ) { + const maybeFn = table[property as keyof typeof target] as + | Function + | never + if (typeof maybeFn === 'function') { + Object.defineProperty(target, property, { + value: toComputed(tableSignal, maybeFn), + configurable: true, + enumerable: true, + }) + return target[property as keyof typeof target] + } + } + return ((target as any)[property] = (table as any)[property]) + }, + has(_, prop) { + return ( + Reflect.has(untracked(tableSignal), prop) || + Reflect.has(tableSignal, prop) + ) + }, + ownKeys() { + return [...Reflect.ownKeys(untracked(tableSignal))] + }, + getOwnPropertyDescriptor() { + return { + enumerable: true, + configurable: true, + } + }, + } satisfies ProxyHandler> +} + +function getExperimentalProxyHandler< + TFeatures extends TableFeatures, + TData extends RowData, +>(tableSignal: Signal>) { + return { + apply() { + return tableSignal() + }, + get(target, property, receiver): any { + if (Reflect.has(target, property)) { + return Reflect.get(target, property) + } + const table = untracked(tableSignal) + return ((target as any)[property] = (table as any)[property]) + }, + has(_, property) { + return ( + Reflect.has(untracked(tableSignal), property) || + Reflect.has(tableSignal, property) + ) + }, + ownKeys() { + return Reflect.ownKeys(untracked(tableSignal)) + }, + getOwnPropertyDescriptor() { + return { + enumerable: true, + configurable: true, + } + }, + } satisfies ProxyHandler> +} diff --git a/packages/angular-table/src/reactivity.ts b/packages/angular-table/src/reactivity.ts new file mode 100644 index 0000000000..d63ad89c78 --- /dev/null +++ b/packages/angular-table/src/reactivity.ts @@ -0,0 +1,115 @@ +import { computed, signal } from '@angular/core' +import { toComputed } from './proxy' +import type { Signal } from '@angular/core' +import type { Table, TableFeature } from '@tanstack/table-core' + +declare module '@tanstack/table-core' { + interface TableOptions_Plugins { + enableExperimentalReactivity?: boolean + } + + interface Table_Plugins { + _rootNotifier?: Signal> + _setRootNotifier?: (signal: Signal>) => void + } +} + +export const reactivityFeature: TableFeature = { + getDefaultTableOptions(table) { + return { enableExperimentalReactivity: false } + }, + constructTableAPIs: (table) => { + if (!table.options.enableExperimentalReactivity) { + return + } + const rootNotifier = signal | null>(null) + + table._rootNotifier = computed(() => rootNotifier()?.(), { + equal: () => false, + }) as any + + table._setRootNotifier = (notifier) => { + rootNotifier.set(notifier) + } + + setReactiveProps(table._rootNotifier!, table, { + skipProperty: skipBaseProperties, + }) + }, + + constructCellAPIs(cell) { + if ( + !cell.table.options.enableExperimentalReactivity || + !cell.table._rootNotifier + ) { + return + } + setReactiveProps(cell.table._rootNotifier, cell, { + skipProperty: skipBaseProperties, + }) + }, + + constructColumnAPIs(column) { + if ( + !column.table.options.enableExperimentalReactivity || + !column.table._rootNotifier + ) { + return + } + setReactiveProps(column.table._rootNotifier, column, { + skipProperty: skipBaseProperties, + }) + }, + + constructHeaderAPIs(header) { + if ( + !header.table.options.enableExperimentalReactivity || + !header.table._rootNotifier + ) { + return + } + setReactiveProps(header.table._rootNotifier, header, { + skipProperty: skipBaseProperties, + }) + }, + + constructRowAPIs(row) { + if ( + !row.table.options.enableExperimentalReactivity || + !row.table._rootNotifier + ) { + return + } + setReactiveProps(row.table._rootNotifier, row, { + skipProperty: skipBaseProperties, + }) + }, +} + +function skipBaseProperties(property: string): boolean { + return property.endsWith('Handler') || !property.startsWith('get') +} + +export function setReactiveProps( + notifier: Signal>, + obj: { [key: string]: any }, + options: { + skipProperty: (property: string) => boolean + }, +) { + const { skipProperty } = options + for (const property in obj) { + const value = obj[property] + if (typeof value !== 'function') { + continue + } + if (skipProperty(property)) { + continue + } + Object.defineProperty(obj, property, { + enumerable: true, + configurable: false, + value: toComputed(notifier, value, property), + }) + } +} diff --git a/packages/angular-table/tests/injectTable.test.ts b/packages/angular-table/tests/injectTable.test.ts index 55630f0eb5..61db8e8ed8 100644 --- a/packages/angular-table/tests/injectTable.test.ts +++ b/packages/angular-table/tests/injectTable.test.ts @@ -75,3 +75,146 @@ describe('injectTable', () => { }) }) }) + +describe('injectTable - Experimental reactivity', () => { + type Data = { id: string; title: string } + const data = signal>([{ id: '1', title: 'Title' }]) + const columns: Array> = [ + { id: 'id', header: 'Id', cell: (context) => context.getValue() }, + { id: 'title', header: 'Title', cell: (context) => context.getValue() }, + ] + const table = injectTable(() => ({ + data: data(), + _features: { ...stockFeatures }, + columns: columns, + getRowId: (row) => row.id, + enableExperimentalReactivity: true, + })) + const tablePropertyKeys = Object.keys(table) + + describe('Proxy', () => { + test('table must be a signal', () => { + expect(isSignal(table)).toEqual(true) + }) + + test('supports "in" operator', () => { + expect('_features' in table).toBe(true) + expect('options' in table).toBe(true) + expect('notFound' in table).toBe(false) + }) + + test('supports "Object.keys"', () => { + const keys = Object.keys(table) + expect(Object.keys(table)).toEqual(keys) + }) + }) + + describe('Table property reactivity', () => { + test.each( + tablePropertyKeys.map((property) => [ + property, + experimentalReactivity_testShouldBeComputedProperty(table, property), + ]), + )('property (%s) is computed -> (%s)', (name, expected) => { + const tableProperty = table[name as keyof typeof table] + expect(isSignal(tableProperty)).toEqual(expected) + }) + }) + + describe('Header property reactivity', () => { + const headers = table.getHeaderGroups() + headers.forEach((headerGroup, index) => { + const headerPropertyKeys = Object.keys(headerGroup) + test.each( + headerPropertyKeys.map((property) => [ + property, + experimentalReactivity_testShouldBeComputedProperty( + headerGroup, + property, + ), + ]), + )( + `HeaderGroup ${headerGroup.id} (${index}) - property (%s) is computed -> (%s)`, + (name, expected) => { + const tableProperty = headerGroup[name as keyof typeof headerGroup] + expect(isSignal(tableProperty)).toEqual(expected) + }, + ) + + const headers = headerGroup.headers + headers.forEach((header, cellIndex) => { + const headerPropertyKeys = Object.keys(header) + test.each( + headerPropertyKeys.map((property) => [ + property, + experimentalReactivity_testShouldBeComputedProperty( + header, + property, + ), + ]), + )( + `HeaderGroup ${headerGroup.id} (${index}) / Header ${header.id} - property (%s) is computed -> (%s)`, + (name, expected) => { + const tableProperty = header[name as keyof typeof header] + expect(isSignal(tableProperty)).toEqual(expected) + }, + ) + }) + }) + }) + + describe('Column property reactivity', () => { + const columns = table.getAllColumns() + columns.forEach((column, index) => { + const columnPropertyKeys = Object.keys(column) + test.each( + columnPropertyKeys.map((property) => [ + property, + experimentalReactivity_testShouldBeComputedProperty(column, property), + ]), + )( + `Column ${column.id} (${index}) - property (%s) is computed -> (%s)`, + (name, expected) => { + const tableProperty = column[name as keyof typeof column] + expect(isSignal(tableProperty)).toEqual(expected) + }, + ) + }) + }) + + describe('Row property reactivity', () => { + const flatRows = table.getRowModel().flatRows + flatRows.forEach((row, index) => { + const rowsPropertyKeys = Object.keys(row) + test.each( + rowsPropertyKeys.map((property) => [ + property, + experimentalReactivity_testShouldBeComputedProperty(row, property), + ]), + )( + `Row ${row.id} (${index}) - property (%s) is computed -> (%s)`, + (name, expected) => { + const tableProperty = row[name as keyof typeof row] + expect(isSignal(tableProperty)).toEqual(expected) + }, + ) + + const cells = row.getAllCells() + cells.forEach((cell, cellIndex) => { + const cellPropertyKeys = Object.keys(cell) + test.each( + cellPropertyKeys.map((property) => [ + property, + experimentalReactivity_testShouldBeComputedProperty(cell, property), + ]), + )( + `Row ${row.id} (${index}) / Cell ${cell.id} - property (%s) is computed -> (%s)`, + (name, expected) => { + const tableProperty = cell[name as keyof typeof cell] + expect(isSignal(tableProperty)).toEqual(expected) + }, + ) + }) + }) + }) +})