From 361577ea48614c84a3c343e8a6b68abdce516690 Mon Sep 17 00:00:00 2001 From: riccardoperra Date: Tue, 26 Mar 2024 21:36:13 +0100 Subject: [PATCH 1/6] feat: signal angular table adapter implementation * update demo --- examples/angular/basic/angular.json | 5 + .../angular/basic/src/app/app.component.html | 100 +++++----- .../angular/basic/src/app/app.component.ts | 30 +-- examples/angular/grouping/angular.json | 6 + .../grouping/src/app/app.component.html | 128 ++++++------- .../angular/grouping/src/app/app.component.ts | 128 +++++-------- examples/angular/row-selection/angular.json | 5 +- .../row-selection/src/app/app.component.html | 178 +++++++++--------- .../row-selection/src/app/app.component.ts | 150 ++++++--------- packages/angular-table/ng-package.json | 1 + packages/angular-table/src/index.ts | 82 +++++--- packages/angular-table/src/proxy.ts | 71 +++++++ 12 files changed, 468 insertions(+), 416 deletions(-) create mode 100644 packages/angular-table/src/proxy.ts diff --git a/examples/angular/basic/angular.json b/examples/angular/basic/angular.json index c23a9f5e89..2c71bfb7c0 100644 --- a/examples/angular/basic/angular.json +++ b/examples/angular/basic/angular.json @@ -4,6 +4,11 @@ "newProjectRoot": "projects", "projects": { "basic": { + "cli": { + "cache": { + "enabled": false + } + }, "projectType": "application", "schematics": { "@schematics/angular:component": { diff --git a/examples/angular/basic/src/app/app.component.html b/examples/angular/basic/src/app/app.component.html index e79dcf8ca5..4777fa1d55 100644 --- a/examples/angular/basic/src/app/app.component.html +++ b/examples/angular/basic/src/app/app.component.html @@ -1,65 +1,61 @@ -@if (table) { - - - @for (headerGroup of table.getHeaderGroups(); track headerGroup.id) { - - @for (header of headerGroup.headers; track header.id) { - @if (!header.isPlaceholder) { - + @for (headerGroup of table.getHeaderGroups(); track headerGroup.id) { + + @for (header of headerGroup.headers; track header.id) { + @if (!header.isPlaceholder) { + - } + > + {{ header }} + + } - - } - - - @for (row of table.getRowModel().rows; track row.id) { - - @for (cell of row.getVisibleCells(); track cell.id) { - - } - - } - - - @for (footerGroup of table.getFooterGroups(); track footerGroup.id) { - - @for (footer of footerGroup.headers; track footer.id) { - - } - - } - -
- +
+ - {{ header }} - -
- - {{ cell }} - -
- - {{ footer }} - -
-} @else { -

loading...

-} + > + {{ footer }} + + + } + + } + + diff --git a/examples/angular/basic/src/app/app.component.ts b/examples/angular/basic/src/app/app.component.ts index a119ee5364..487f596d98 100644 --- a/examples/angular/basic/src/app/app.component.ts +++ b/examples/angular/basic/src/app/app.component.ts @@ -1,10 +1,14 @@ -import { Component } from '@angular/core' +import { + ChangeDetectionStrategy, + Component, + type OnInit, + signal, +} from '@angular/core' import { RouterOutlet } from '@angular/router' import { ColumnDef, - FlexRenderDirective, - Table, createAngularTable, + FlexRenderDirective, getCoreRowModel, } from '@tanstack/angular-table' @@ -87,16 +91,18 @@ const defaultData: Person[] = [ imports: [RouterOutlet, FlexRenderDirective], templateUrl: './app.component.html', styleUrl: './app.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush }) -export class AppComponent { - data: Person[] = [] - table!: Table +export class AppComponent implements OnInit { + data = signal([]) + + table = createAngularTable(() => ({ + data: this.data(), + columns: defaultColumns, + getCoreRowModel: getCoreRowModel(), + })) + ngOnInit() { - this.data = [...defaultData] - this.table = createAngularTable({ - data: this.data, - columns: defaultColumns, - getCoreRowModel: getCoreRowModel(), - }) + this.data.set(defaultData) } } diff --git a/examples/angular/grouping/angular.json b/examples/angular/grouping/angular.json index 7fd7367537..a97add3d36 100644 --- a/examples/angular/grouping/angular.json +++ b/examples/angular/grouping/angular.json @@ -82,5 +82,11 @@ } } } + }, + "cli": { + "analytics": false, + "cache": { + "enabled": false + } } } diff --git a/examples/angular/grouping/src/app/app.component.html b/examples/angular/grouping/src/app/app.component.html index 3c42725e52..fe4a7f67b0 100644 --- a/examples/angular/grouping/src/app/app.component.html +++ b/examples/angular/grouping/src/app/app.component.html @@ -1,17 +1,16 @@

Grouping

- @if (table) { - - - @for (headerGroup of table.getHeaderGroups(); track headerGroup.id) { - - @for (header of headerGroup.headers; track header.id) { - + + +
- @if (!header.isPlaceholder && header.column.getCanGroup()) { -
@@ -137,12 +135,14 @@

Grouping

class="pagination-select" (change)="onPageSizeChange($event)" > - + @for (pageSize of [10, 20, 30, 40, 50]; track pageSize) { + + } +
diff --git a/examples/angular/grouping/src/app/app.component.ts b/examples/angular/grouping/src/app/app.component.ts index 6efb0c9b05..1b7ac7f166 100644 --- a/examples/angular/grouping/src/app/app.component.ts +++ b/examples/angular/grouping/src/app/app.component.ts @@ -1,105 +1,71 @@ import { CommonModule, NgFor, NgIf } from '@angular/common' +import { ChangeDetectionStrategy, Component, signal } from '@angular/core' import { - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - OnDestroy, - OnInit, -} from '@angular/core' -import { + createAngularTable, ExpandedState, FlexRenderDirective, - GroupingState, - PaginationState, - Table, - Updater, - createAngularTable, getCoreRowModel, getExpandedRowModel, getFilteredRowModel, getGroupedRowModel, getPaginationRowModel, + GroupingState, + PaginationState, + Updater, } from '@tanstack/angular-table' - -import { BehaviorSubject, Subject, combineLatest, takeUntil } from 'rxjs' -import { Person, columns } from './columns' +import { columns } from './columns' import { mockData } from './mockdata' @Component({ selector: 'app-root', standalone: true, - imports: [NgIf, NgFor, FlexRenderDirective, CommonModule], + imports: [FlexRenderDirective, CommonModule], templateUrl: './app.component.html', styleUrl: './app.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class AppComponent implements OnInit, OnDestroy { +export class AppComponent { title = 'grouping' - - private destroy$ = new Subject() - data = mockData(10000) - groupingState = new BehaviorSubject([]) - expandedState = new BehaviorSubject({}) - paginationState = new BehaviorSubject({ + data = signal(mockData(10000)) + groupingState = signal([]) + expandedState = signal({}) + paginationState = signal({ pageIndex: 0, pageSize: 10, } as PaginationState) - table!: Table - expanded: ExpandedState = {} - constructor(private cdr: ChangeDetectorRef) {} + expanded = signal({}) - ngOnInit() { - this.createTable() - combineLatest([ - this.expandedState, - this.groupingState, - this.paginationState, - ]) - .pipe(takeUntil(this.destroy$)) - .subscribe(([expandedState, groupingState, paginationState]) => { - this.table.options.state.grouping = groupingState - this.table.options.state.expanded = expandedState - this.table.options.state.pagination = paginationState - this.cdr.detectChanges() - }) - } - - createTable() { - this.table = createAngularTable({ - data: this.data, - columns: columns, - state: { - grouping: this.groupingState.getValue(), - expanded: this.expandedState.getValue(), - pagination: this.paginationState.getValue(), - }, - onGroupingChange: (updaterOrValue: Updater) => { - const group = - typeof updaterOrValue === 'function' - ? updaterOrValue([...this.groupingState.getValue()]) - : updaterOrValue - this.groupingState.next(group) - }, - onExpandedChange: updater => { - const expand = - typeof updater === 'function' - ? updater(this.expandedState.getValue()) - : updater - this.expandedState.next(expand) - }, - debugTable: true, - onPaginationChange: val => { - const page = - typeof val === 'function' ? val(this.paginationState.getValue()) : val - this.paginationState.next(page) - }, - getExpandedRowModel: getExpandedRowModel(), - getGroupedRowModel: getGroupedRowModel(), - getCoreRowModel: getCoreRowModel(), - getPaginationRowModel: getPaginationRowModel(), - getFilteredRowModel: getFilteredRowModel(), - }) - } + table = createAngularTable(() => ({ + data: this.data(), + columns: columns, + state: { + grouping: this.groupingState(), + expanded: this.expandedState(), + pagination: this.paginationState(), + }, + onGroupingChange: (updaterOrValue: Updater) => { + const group = + typeof updaterOrValue === 'function' + ? updaterOrValue([...this.groupingState()]) + : updaterOrValue + this.groupingState.set(group) + }, + onExpandedChange: updater => { + const expand = + typeof updater === 'function' ? updater(this.expandedState()) : updater + this.expandedState.set(expand) + }, + onPaginationChange: val => { + const page = typeof val === 'function' ? val(this.paginationState()) : val + this.paginationState.set(page) + }, + debugTable: true, + getExpandedRowModel: getExpandedRowModel(), + getGroupedRowModel: getGroupedRowModel(), + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getFilteredRowModel: getFilteredRowModel(), + })) onPageInputChange(event: any): void { const page = event.target.value ? Number(event.target.value) - 1 : 0 @@ -111,10 +77,6 @@ export class AppComponent implements OnInit, OnDestroy { } refreshData() { - this.table.options.data = mockData(1000) - } - ngOnDestroy(): void { - this.destroy$.next() - this.destroy$.complete() + this.data.set(mockData(1000)) } } diff --git a/examples/angular/row-selection/angular.json b/examples/angular/row-selection/angular.json index 8c777a97ac..76c68d93ac 100644 --- a/examples/angular/row-selection/angular.json +++ b/examples/angular/row-selection/angular.json @@ -84,6 +84,9 @@ } }, "cli": { - "analytics": false + "analytics": false, + "cache": { + "enabled": false + } } } diff --git a/examples/angular/row-selection/src/app/app.component.html b/examples/angular/row-selection/src/app/app.component.html index 4263ebb092..cbb547fdf6 100644 --- a/examples/angular/row-selection/src/app/app.component.html +++ b/examples/angular/row-selection/src/app/app.component.html @@ -1,107 +1,105 @@

Select

-@if (table) { - - - @for (headerGroup of table.getHeaderGroups(); track headerGroup.id) { - - @for (header of headerGroup.headers; track header.id) { - + @for (headerGroup of table.getHeaderGroups(); track headerGroup.id) { + + @for (header of headerGroup.headers; track header.id) { + - } - - } - - - @for (row of table.getRowModel().rows; track row.id) { - - @for (cell of row.getVisibleCells(); track cell.id) { - - } - - } - - - - - + > + {{ renderCell }} + + } + + } - -
- @if (!header.isPlaceholder) { - @if (header.id == 'select') { - - } @else { - +
+ @if (!header.isPlaceholder) { + @if (header.id == 'select') { + + } @else { + - {{ headerCell }} - @if (header.column.getCanSort()) { - + > + {{ headerCell }} + @if (header.column.getCanSort()) { + {{ - header.column.getIsSorted() === 'asc' - ? 'arrow_drop_up' - : header.column.getIsSorted() === 'desc' - ? 'arrow_drop_down' - : '' - }} + header.column.getIsSorted() === 'asc' + ? 'arrow_drop_up' + : header.column.getIsSorted() === 'desc' + ? 'arrow_drop_down' + : '' + }} - } - - @if (header.column.getCanFilter()) { - } + + @if (header.column.getCanFilter()) { + } } -
- @if (cell.id.endsWith('select')) { - - } @else { - + } @else { + - {{ renderCell }} - - } -
- - - Page Rows ({{ table.getRowModel().rows.length }}) -
-} + } + + + + + + + + Page Rows ({{ table.getRowModel().rows.length }}) + + + +
- {{ getRowSelectionLength() | async }} of + {{ rowSelectionLength() }} of {{ table.getPreFilteredRowModel().rows.length }} Total Rows Selected
@@ -154,9 +152,11 @@

Select

class="pagination-select" (change)="onPageSizeChange($event)" > - + @for (pageSize of [10, 20, 30, 40, 50]; track pageSize) { + + }
diff --git a/examples/angular/row-selection/src/app/app.component.ts b/examples/angular/row-selection/src/app/app.component.ts index 9ce0594d28..c3b9e09d0d 100644 --- a/examples/angular/row-selection/src/app/app.component.ts +++ b/examples/angular/row-selection/src/app/app.component.ts @@ -1,107 +1,85 @@ -import { AsyncPipe, NgFor, NgIf } from '@angular/common' -import { ChangeDetectorRef, Component, OnInit } from '@angular/core' +import { Component, computed, signal } from '@angular/core' import { ColumnFiltersState, - FlexRenderDirective, - PaginationState, - RowSelectionState, - SortingState, - Table, createAngularTable, + FlexRenderDirective, getCoreRowModel, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, + PaginationState, + RowSelectionState, + SortingState, } from '@tanstack/angular-table' -import { BehaviorSubject, Subject, combineLatest, map, takeUntil } from 'rxjs' -import { Person, columns } from './columns' +import { columns, Person } from './columns' import { FilterComponent } from './filter' import { mockData } from './mockdata' @Component({ selector: 'app-root', standalone: true, - imports: [NgFor, NgIf, AsyncPipe, FilterComponent, FlexRenderDirective], + imports: [FilterComponent, FlexRenderDirective], templateUrl: './app.component.html', styleUrl: './app.component.scss', }) -export class AppComponent implements OnInit { - table!: Table - private destroy$ = new Subject() - private rowSelectionState = new BehaviorSubject({}) - private paginationState = new BehaviorSubject({ +export class AppComponent { + private rowSelectionState = signal({}) + private paginationState = signal({ pageIndex: 0, pageSize: 10, - } as PaginationState) - private columnFilterState = new BehaviorSubject([]) - sortingState = new BehaviorSubject([]) + }) + private columnFilterState = signal([]) + sortingState = signal([]) data: Person[] = mockData(10000) - constructor(private cdr: ChangeDetectorRef) {} + table = createAngularTable(() => ({ + data: this.data, + columns: columns, + state: { + rowSelection: this.rowSelectionState(), + pagination: this.paginationState(), + columnFilters: this.columnFilterState(), + sorting: this.sortingState(), + }, + enableRowSelection: true, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + debugTable: true, + onRowSelectionChange: updaterOrValue => { + this.rowSelectionState.set( + typeof updaterOrValue === 'function' + ? updaterOrValue(this.rowSelectionState()) + : updaterOrValue + ) + }, + onPaginationChange: updaterOrValue => { + this.paginationState.set( + typeof updaterOrValue === 'function' + ? updaterOrValue(this.paginationState()) + : updaterOrValue + ) + }, + onColumnFiltersChange: updaterOrValue => { + this.columnFilterState.set( + typeof updaterOrValue === 'function' + ? updaterOrValue(this.columnFilterState()) + : updaterOrValue + ) + }, + onSortingChange: updaterOrValue => { + this.sortingState.set( + typeof updaterOrValue === 'function' + ? updaterOrValue(this.sortingState()) + : updaterOrValue + ) + }, + })) - ngOnInit() { - this.createTable() - combineLatest([ - this.rowSelectionState, - this.paginationState, - this.sortingState, - ]) - .pipe(takeUntil(this.destroy$)) - .subscribe(([rowSelectionState, paginationState, sortingState]) => { - this.table.options.state.rowSelection = rowSelectionState - this.table.options.state.pagination = paginationState - this.table.options.state.sorting = sortingState - this.cdr.detectChanges() - }) - } - createTable() { - this.table = createAngularTable({ - data: this.data, - columns: columns, - state: { - rowSelection: this.rowSelectionState.getValue(), - pagination: this.paginationState.getValue(), - columnFilters: this.columnFilterState.getValue(), - sorting: this.sortingState.getValue(), - }, - enableRowSelection: true, - onRowSelectionChange: updaterOrValue => { - this.rowSelectionState.next( - typeof updaterOrValue === 'function' - ? updaterOrValue(this.rowSelectionState.getValue()) - : updaterOrValue - ) - }, - onPaginationChange: Updater => { - const newvalue = - typeof Updater === 'function' - ? Updater(this.paginationState.getValue()) - : Updater - this.table.options.state.pagination = newvalue - this.paginationState.next(newvalue) - }, - onColumnFiltersChange: updater => { - const filter = - typeof updater === 'function' - ? updater(this.columnFilterState.getValue()) - : updater - this.table.options.state.columnFilters = filter - this.columnFilterState.next(filter) - }, - onSortingChange: updaterOrValue => { - const sorting = - typeof updaterOrValue == 'function' - ? updaterOrValue([...this.sortingState.getValue()]) - : updaterOrValue - this.sortingState.next(sorting) - }, - getCoreRowModel: getCoreRowModel(), - getFilteredRowModel: getFilteredRowModel(), - getPaginationRowModel: getPaginationRowModel(), - getSortedRowModel: getSortedRowModel(), - debugTable: true, - }) - } + rowSelectionLength = computed( + () => Object.keys(this.rowSelectionState()).length + ) onPageInputChange(event: Event) { const inputElement = event.target as HTMLInputElement @@ -112,12 +90,4 @@ export class AppComponent implements OnInit { onPageSizeChange(event: any) { this.table.setPageSize(Number(event.target.value)) } - - getRowSelectionLength() { - return this.rowSelectionState.pipe(map(val => Object.keys(val).length)) - } - ngOnDestroy(): void { - this.destroy$.next() - this.destroy$.complete() - } } diff --git a/packages/angular-table/ng-package.json b/packages/angular-table/ng-package.json index 392237ec0f..fabab36e39 100644 --- a/packages/angular-table/ng-package.json +++ b/packages/angular-table/ng-package.json @@ -4,5 +4,6 @@ "lib": { "entryFile": "src/index.ts" }, + "deleteDestPath": false, "allowedNonPeerDependencies": ["@tanstack/table-core"] } diff --git a/packages/angular-table/src/index.ts b/packages/angular-table/src/index.ts index fac3a44ba3..f2dd4cf9ee 100644 --- a/packages/angular-table/src/index.ts +++ b/packages/angular-table/src/index.ts @@ -1,16 +1,25 @@ import { + computed, Directive, + effect, + inject, + Injector, Input, OnInit, + runInInjectionContext, + signal, TemplateRef, + untracked, ViewContainerRef, } from '@angular/core' import { + createTable, + getCoreRowModel, RowData, TableOptions, TableOptionsResolved, - createTable, } from '@tanstack/table-core' +import { proxifyTable, type TableProxy } from './proxy' export * from '@tanstack/table-core' @@ -67,31 +76,54 @@ export class FlexRenderDirective implements OnInit { } export function createAngularTable( - options: TableOptions -) { - const resolvedOptions: TableOptionsResolved = { - state: {}, - onStateChange: () => {}, - renderFallbackValue: null, - ...options, - } + options: () => TableOptions +): TableProxy { + const injector = inject(Injector) + return runInInjectionContext(injector, () => { + const resolvedOptionsSignal = computed>(() => { + return { + state: {}, + onStateChange: () => {}, + renderFallbackValue: null, + ...options(), + } + }) - let table = createTable(resolvedOptions) - let state = table.initialState - // Compose the default state above with any user state. - table.setOptions((prev: any) => { - return { - ...prev, - ...options, - state: { - ...state, - ...options.state, - }, - onStateChange: (updater: any) => { - options.onStateChange?.(updater) - }, + const table = signal(createTable(resolvedOptionsSignal()), { + equal: () => false, + }) + const state = signal(untracked(table).initialState) + + function updateOptions() { + const tableState = state() + const resolvedOptions = resolvedOptionsSignal() + untracked(() => { + table().setOptions(prev => ({ + ...prev, + ...resolvedOptions, + state: { ...tableState, ...resolvedOptions.state }, + onStateChange: updater => { + const value = + updater instanceof Function ? updater(tableState) : updater + state.set(value) + resolvedOptions.onStateChange?.(updater) + }, + })) + table.update(v => v) + }) } - }) - return table + updateOptions() + + let skip = true + effect(() => { + void [state(), resolvedOptionsSignal()] + if (skip) { + return (skip = false) + } + untracked(() => updateOptions()) + }) + + return proxifyTable(table.asReadonly()) + }) } diff --git a/packages/angular-table/src/proxy.ts b/packages/angular-table/src/proxy.ts new file mode 100644 index 0000000000..fd9995770c --- /dev/null +++ b/packages/angular-table/src/proxy.ts @@ -0,0 +1,71 @@ +import { computed, isSignal, type Signal, untracked } from '@angular/core' +import type { Table } from '@tanstack/table-core' + +type _TableProxyAccessor = T extends () => infer U ? Signal : never + +type _TableProxy> = { + [K in keyof T]: K extends `get${string}` ? _TableProxyAccessor : T[K] +} & { + options: Signal +} + +export type TableProxy = Signal> & _TableProxy> + +// WIP +export function proxifyTable(tableSignal: Signal>) { + const proxyTable = new Proxy(tableSignal, { + get(target: Signal>, property: keyof Table): any { + const untypedTarget = target as any + const table = untracked(target) + + if (!(property in table)) { + return untypedTarget[property] + } + + if (isSignal(untypedTarget[property])) { + return untypedTarget[property] + } + + // Try to transform all accessors into computed, skipping + // handlers since they don't hold any value + if (property.startsWith('get') && !property.endsWith('Handler')) { + const maybeFn = table[property] as Function | never + if (typeof maybeFn === 'function') { + // Accessors with no arguments should be treated as computed functions + if (maybeFn.length === 0) { + Object.defineProperty(untypedTarget, property, { + value: computed(() => untypedTarget()[property]()), + configurable: true, + enumerable: true, + }) + } + // Accessors with one argument could be memoized, e.g. `table.getHeaderGroups` + if (maybeFn.length === 1) { + Object.defineProperty(untypedTarget, property, { + // TODO: check if this make sense + // Computed could run again but emitted value passes through the `memo` fn + value: computed(() => untypedTarget()[property](), { equal: () => false }), + configurable: true, + enumerable: true, + }) + } + + // Accessors with arguments could be impure functions + if (maybeFn.length > 1) { + Object.defineProperty(untypedTarget, property, { + value: target()[property], + configurable: true, + enumerable: true, + }) + } + } + } + + return table[property] + }, + }) + + return Object.assign(proxyTable, { + options: computed(() => tableSignal().options), + }) as TableProxy +} From 8516ea7dd7cf513ca7074a1593048f84ba9dfe98 Mon Sep 17 00:00:00 2001 From: riccardoperra Date: Tue, 26 Mar 2024 22:12:56 +0100 Subject: [PATCH 2/6] feat: table proxy detect memoized fns --- packages/angular-table/src/proxy.ts | 52 +++++++++++++++++++++-------- 1 file changed, 39 insertions(+), 13 deletions(-) diff --git a/packages/angular-table/src/proxy.ts b/packages/angular-table/src/proxy.ts index fd9995770c..be57b38a31 100644 --- a/packages/angular-table/src/proxy.ts +++ b/packages/angular-table/src/proxy.ts @@ -1,10 +1,10 @@ import { computed, isSignal, type Signal, untracked } from '@angular/core' import type { Table } from '@tanstack/table-core' -type _TableProxyAccessor = T extends () => infer U ? Signal : never +type TableProxyAccessor = T extends () => infer U ? Signal : never type _TableProxy> = { - [K in keyof T]: K extends `get${string}` ? _TableProxyAccessor : T[K] + [K in keyof T]: K extends `get${string}` ? TableProxyAccessor : T[K] } & { options: Signal } @@ -13,21 +13,25 @@ export type TableProxy = Signal> & _TableProxy> // WIP export function proxifyTable(tableSignal: Signal>) { + const propertyCache: Record = {} + const proxyTable = new Proxy(tableSignal, { get(target: Signal>, property: keyof Table): any { + if (propertyCache[property]) { + return propertyCache[property] + } const untypedTarget = target as any - const table = untracked(target) - - if (!(property in table)) { + if (untypedTarget[property]) { return untypedTarget[property] } - if (isSignal(untypedTarget[property])) { + const table = untracked(target) + if (!(property in table)) { return untypedTarget[property] } - // Try to transform all accessors into computed, skipping - // handlers since they don't hold any value + // Attempt to convert all accessors into computed ones, + // excluding handlers as they do not retain any value. if (property.startsWith('get') && !property.endsWith('Handler')) { const maybeFn = table[property] as Function | never if (typeof maybeFn === 'function') { @@ -39,18 +43,40 @@ export function proxifyTable(tableSignal: Signal>) { enumerable: true, }) } - // Accessors with one argument could be memoized, e.g. `table.getHeaderGroups` + + // Accessors with one argument could be a memoized fn (e.g. `getHeaderGroups(memoArgs)`) + // or a fn with some dependent parameter in their signature (e.g. `getIsSomeRowsPinned(position)`) + // TODO: need to check better this, since there are some fns that have an optional argument if (maybeFn.length === 1) { + let computedSignal: Signal | undefined + const maybeComputedTrap = new Proxy(maybeFn, { + apply(target: any, thisArg: any, argArray: any[]): any { + if (argArray.length === 0) { + if (computedSignal) { + return computedSignal() + } + computedSignal = computed(() => + Reflect.apply(target, thisArg, argArray) + ) + // We don't need the proxy anymore, so we'll override the cache value + // in order to use the existing signal in the next change detection cycle + propertyCache[property] = computedSignal + return computedSignal() + } + return Reflect.apply(target, thisArg, argArray) + }, + }) + + propertyCache[property] = maybeComputedTrap + Object.defineProperty(untypedTarget, property, { - // TODO: check if this make sense - // Computed could run again but emitted value passes through the `memo` fn - value: computed(() => untypedTarget()[property](), { equal: () => false }), + value: maybeComputedTrap, configurable: true, enumerable: true, }) } - // Accessors with arguments could be impure functions + // Accessors with arguments could be impure functions, so we can't memoize the value if (maybeFn.length > 1) { Object.defineProperty(untypedTarget, property, { value: target()[property], From 9b195b35c218c73fd5c8ce38c21ed755ab05444b Mon Sep 17 00:00:00 2001 From: riccardoperra Date: Tue, 26 Mar 2024 22:41:20 +0100 Subject: [PATCH 3/6] fix proxy property returning value --- packages/angular-table/src/proxy.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/angular-table/src/proxy.ts b/packages/angular-table/src/proxy.ts index be57b38a31..751f832484 100644 --- a/packages/angular-table/src/proxy.ts +++ b/packages/angular-table/src/proxy.ts @@ -55,9 +55,7 @@ export function proxifyTable(tableSignal: Signal>) { if (computedSignal) { return computedSignal() } - computedSignal = computed(() => - Reflect.apply(target, thisArg, argArray) - ) + computedSignal = computed(() => untypedTarget()[property]()) // We don't need the proxy anymore, so we'll override the cache value // in order to use the existing signal in the next change detection cycle propertyCache[property] = computedSignal @@ -87,7 +85,7 @@ export function proxifyTable(tableSignal: Signal>) { } } - return table[property] + return untypedTarget[property] || table[property] }, }) From bf32e668e21a1e998294cbb665982548e4caf955 Mon Sep 17 00:00:00 2001 From: riccardoperra Date: Tue, 26 Mar 2024 23:24:56 +0100 Subject: [PATCH 4/6] feat: improve naming --- .../angular/grouping/src/app/app.component.ts | 32 ++++++++++++++++--- packages/angular-table/src/index.ts | 5 ++- packages/angular-table/src/proxy.ts | 22 ++++++++----- 3 files changed, 43 insertions(+), 16 deletions(-) diff --git a/examples/angular/grouping/src/app/app.component.ts b/examples/angular/grouping/src/app/app.component.ts index 1b7ac7f166..b6f7f47feb 100644 --- a/examples/angular/grouping/src/app/app.component.ts +++ b/examples/angular/grouping/src/app/app.component.ts @@ -1,5 +1,5 @@ -import { CommonModule, NgFor, NgIf } from '@angular/common' -import { ChangeDetectionStrategy, Component, signal } from '@angular/core' +import {CommonModule} from '@angular/common' +import {ChangeDetectionStrategy, Component, computed, effect, signal} from '@angular/core' import { createAngularTable, ExpandedState, @@ -13,8 +13,8 @@ import { PaginationState, Updater, } from '@tanstack/angular-table' -import { columns } from './columns' -import { mockData } from './mockdata' +import {columns} from './columns' +import {mockData} from './mockdata' @Component({ selector: 'app-root', @@ -65,7 +65,29 @@ export class AppComponent { getCoreRowModel: getCoreRowModel(), getPaginationRowModel: getPaginationRowModel(), getFilteredRowModel: getFilteredRowModel(), - })) + })); + + constructor() { + effect(() => { + // run on every state/option change + this.table().getPageOptions(); + }); + + // more granular state, run on every "getPageOptions" change (e.g. only when pagination state change) + effect(() => { + this.table.getPageOptions() + }); + + // granular state is still possible using computed manually, but it must be done manually + const pageOptions = computed(() => this.table().getPageOptions()); + effect(() => { + pageOptions() + }); + + // these two are lines does the same thing, they access to the same table instance property. + this.table().setPageSize(...) + this.table.setPageSize() // this one is evaluated lazily with proxy + } onPageInputChange(event: any): void { const page = event.target.value ? Number(event.target.value) - 1 : 0 diff --git a/packages/angular-table/src/index.ts b/packages/angular-table/src/index.ts index f2dd4cf9ee..7add01bd06 100644 --- a/packages/angular-table/src/index.ts +++ b/packages/angular-table/src/index.ts @@ -14,12 +14,11 @@ import { } from '@angular/core' import { createTable, - getCoreRowModel, RowData, TableOptions, TableOptionsResolved, } from '@tanstack/table-core' -import { proxifyTable, type TableProxy } from './proxy' +import { proxifyTable, type TableResult } from './proxy' export * from '@tanstack/table-core' @@ -77,7 +76,7 @@ export class FlexRenderDirective implements OnInit { export function createAngularTable( options: () => TableOptions -): TableProxy { +): TableResult { const injector = inject(Injector) return runInInjectionContext(injector, () => { const resolvedOptionsSignal = computed>(() => { diff --git a/packages/angular-table/src/proxy.ts b/packages/angular-table/src/proxy.ts index 751f832484..f803a86813 100644 --- a/packages/angular-table/src/proxy.ts +++ b/packages/angular-table/src/proxy.ts @@ -1,18 +1,24 @@ import { computed, isSignal, type Signal, untracked } from '@angular/core' import type { Table } from '@tanstack/table-core' +type Prettify = { + [K in keyof T]: T[K] +} & {} + type TableProxyAccessor = T extends () => infer U ? Signal : never -type _TableProxy> = { - [K in keyof T]: K extends `get${string}` ? TableProxyAccessor : T[K] -} & { - options: Signal -} +type TableProxy> = Prettify< + { + [K in keyof T]: K extends `get${string}` ? TableProxyAccessor : T[K] + } & { + options: Signal + } +> -export type TableProxy = Signal> & _TableProxy> +export type TableResult = Signal> & TableProxy> // WIP -export function proxifyTable(tableSignal: Signal>) { +export function proxifyTable(tableSignal: Signal>): TableResult { const propertyCache: Record = {} const proxyTable = new Proxy(tableSignal, { @@ -91,5 +97,5 @@ export function proxifyTable(tableSignal: Signal>) { return Object.assign(proxyTable, { options: computed(() => tableSignal().options), - }) as TableProxy + }) as TableResult } From 129f9217a352c1ce04a9e6cbf902cdbe4a61d7eb Mon Sep 17 00:00:00 2001 From: riccardoperra Date: Wed, 27 Mar 2024 20:51:27 +0100 Subject: [PATCH 5/6] save new reference of table signal on every update --- packages/angular-table/src/index.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/angular-table/src/index.ts b/packages/angular-table/src/index.ts index 7add01bd06..0887b67441 100644 --- a/packages/angular-table/src/index.ts +++ b/packages/angular-table/src/index.ts @@ -88,7 +88,7 @@ export function createAngularTable( } }) - const table = signal(createTable(resolvedOptionsSignal()), { + const table = signal(createTable(untracked(resolvedOptionsSignal)), { equal: () => false, }) const state = signal(untracked(table).initialState) @@ -108,19 +108,17 @@ export function createAngularTable( resolvedOptions.onStateChange?.(updater) }, })) - table.update(v => v) }) } updateOptions() - let skip = true effect(() => { void [state(), resolvedOptionsSignal()] - if (skip) { - return (skip = false) - } - untracked(() => updateOptions()) + untracked(() => { + updateOptions() + table.update(value => ({ ...value })) + }) }) return proxifyTable(table.asReadonly()) From 3aab6373c4d0bd8777b23f9ad3615427ffbd69cb Mon Sep 17 00:00:00 2001 From: riccardoperra Date: Wed, 27 Mar 2024 22:02:39 +0100 Subject: [PATCH 6/6] computed trap proxy for fns with 1 argument --- packages/angular-table/src/proxy.ts | 46 +++++++++++++---------------- 1 file changed, 21 insertions(+), 25 deletions(-) diff --git a/packages/angular-table/src/proxy.ts b/packages/angular-table/src/proxy.ts index f803a86813..4e441e1c70 100644 --- a/packages/angular-table/src/proxy.ts +++ b/packages/angular-table/src/proxy.ts @@ -1,11 +1,15 @@ -import { computed, isSignal, type Signal, untracked } from '@angular/core' -import type { Table } from '@tanstack/table-core' +import { computed, type Signal, untracked } from '@angular/core' +import { type Table } from '@tanstack/table-core' type Prettify = { [K in keyof T]: T[K] } & {} -type TableProxyAccessor = T extends () => infer U ? Signal : never +type TableProxyAccessor = T extends (...args: any[]) => any + ? T + : T extends () => infer U + ? Signal + : never type TableProxy> = Prettify< { @@ -19,13 +23,8 @@ export type TableResult = Signal> & TableProxy> // WIP export function proxifyTable(tableSignal: Signal>): TableResult { - const propertyCache: Record = {} - const proxyTable = new Proxy(tableSignal, { get(target: Signal>, property: keyof Table): any { - if (propertyCache[property]) { - return propertyCache[property] - } const untypedTarget = target as any if (untypedTarget[property]) { return untypedTarget[property] @@ -52,29 +51,26 @@ export function proxifyTable(tableSignal: Signal>): TableResult { // Accessors with one argument could be a memoized fn (e.g. `getHeaderGroups(memoArgs)`) // or a fn with some dependent parameter in their signature (e.g. `getIsSomeRowsPinned(position)`) - // TODO: need to check better this, since there are some fns that have an optional argument + // TODO: need to check better this, since there are some fns that have an optional argument. + // This may work also for fns with more than 1 argument. if (maybeFn.length === 1) { - let computedSignal: Signal | undefined - const maybeComputedTrap = new Proxy(maybeFn, { + const computedCache: Record> = {} + const computedTrap = new Proxy(maybeFn, { apply(target: any, thisArg: any, argArray: any[]): any { - if (argArray.length === 0) { - if (computedSignal) { - return computedSignal() - } - computedSignal = computed(() => untypedTarget()[property]()) - // We don't need the proxy anymore, so we'll override the cache value - // in order to use the existing signal in the next change detection cycle - propertyCache[property] = computedSignal - return computedSignal() + const args = JSON.stringify(argArray) + if (computedCache[args]) { + return computedCache[args]?.() } - return Reflect.apply(target, thisArg, argArray) + const computedSignal = computed(() => { + untypedTarget()[property]() + return Reflect.apply(target, thisArg, argArray) + }) + computedCache[args] = computedSignal + return computedSignal() }, }) - - propertyCache[property] = maybeComputedTrap - Object.defineProperty(untypedTarget, property, { - value: maybeComputedTrap, + value: computedTrap, configurable: true, enumerable: true, })