diff --git a/packages/angular-table/package.json b/packages/angular-table/package.json index 6180808e15..5a91ec0b4d 100644 --- a/packages/angular-table/package.json +++ b/packages/angular-table/package.json @@ -46,6 +46,7 @@ "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/src/angularReactivityFeature.ts b/packages/angular-table/src/angularReactivityFeature.ts index 87d749ca26..7afcb89eec 100644 --- a/packages/angular-table/src/angularReactivityFeature.ts +++ b/packages/angular-table/src/angularReactivityFeature.ts @@ -1,5 +1,5 @@ -import { computed, signal } from '@angular/core' -import { toComputed } from './proxy' +import { computed, isSignal, signal } from '@angular/core' +import { defineLazyComputedProperty, markReactive } from './reactivityUtils' import type { Signal } from '@angular/core' import type { RowData, @@ -20,16 +20,26 @@ declare module '@tanstack/table-core' { > extends Table_AngularReactivity {} } +type SkipPropertyFn = (property: string) => boolean + +export interface AngularReactivityFlags { + header: boolean | SkipPropertyFn + column: boolean | SkipPropertyFn + row: boolean | SkipPropertyFn + cell: boolean | SkipPropertyFn + table: boolean | SkipPropertyFn +} + interface TableOptions_AngularReactivity { - enableExperimentalReactivity?: boolean + reactivity?: Partial } interface Table_AngularReactivity< TFeatures extends TableFeatures, TData extends RowData, > { - _rootNotifier?: Signal> - _setRootNotifier?: (signal: Signal>) => void + get: Signal> + _setTableNotifier: (signal: Signal>) => void } interface AngularReactivityFeatureConstructors< @@ -40,78 +50,104 @@ interface AngularReactivityFeatureConstructors< Table: Table_AngularReactivity } +const getUserSkipPropertyFn = ( + value: undefined | null | boolean | SkipPropertyFn, + defaultPropertyFn: SkipPropertyFn, +) => { + if (typeof value === 'boolean') { + return defaultPropertyFn + } + return value ?? defaultPropertyFn +} + export function constructAngularReactivityFeature< TFeatures extends TableFeatures, TData extends RowData, >(): TableFeature> { return { getDefaultTableOptions(table) { - return { enableExperimentalReactivity: false } + return { + reactivity: { + header: true, + column: true, + row: true, + cell: true, + table: true, + }, + } }, constructTableAPIs: (table) => { - if (!table.options.enableExperimentalReactivity) { - return - } const rootNotifier = signal | null>(null) - table._rootNotifier = computed(() => rootNotifier()?.(), { - equal: () => false, - }) as any - - table._setRootNotifier = (notifier) => { + table._setTableNotifier = (notifier) => { rootNotifier.set(notifier) } - setReactiveProps(table._rootNotifier!, table, { - skipProperty: skipBaseProperties, + table.get = computed(() => rootNotifier()!(), { + equal: () => false, + }) + + if (table.options.reactivity?.table === false) { + return + } + markReactive(table) + setReactiveProps(table.get, table, { + skipProperty: getUserSkipPropertyFn( + table.options.reactivity?.table, + skipBaseProperties, + ), }) }, constructCellAPIs(cell) { - if ( - !cell._table.options.enableExperimentalReactivity || - !cell._table._rootNotifier - ) { + if (cell._table.options.reactivity?.cell === false) { return } - setReactiveProps(cell._table._rootNotifier, cell, { - skipProperty: skipBaseProperties, + markReactive(cell) + setReactiveProps(cell._table.get, cell, { + skipProperty: getUserSkipPropertyFn( + cell._table.options.reactivity?.cell, + skipBaseProperties, + ), }) }, constructColumnAPIs(column) { - if ( - !column._table.options.enableExperimentalReactivity || - !column._table._rootNotifier - ) { + if (column._table.options.reactivity?.column === false) { return } - setReactiveProps(column._table._rootNotifier, column, { - skipProperty: skipBaseProperties, + markReactive(column) + setReactiveProps(column._table.get, column, { + skipProperty: getUserSkipPropertyFn( + column._table.options.reactivity?.cell, + skipBaseProperties, + ), }) }, constructHeaderAPIs(header) { - if ( - !header._table.options.enableExperimentalReactivity || - !header._table._rootNotifier - ) { + if (header._table.options.reactivity?.header === false) { return } - setReactiveProps(header._table._rootNotifier, header, { - skipProperty: skipBaseProperties, + markReactive(header) + setReactiveProps(header._table.get, header, { + skipProperty: getUserSkipPropertyFn( + header._table.options.reactivity?.cell, + skipBaseProperties, + ), }) }, constructRowAPIs(row) { - if ( - !row._table.options.enableExperimentalReactivity || - !row._table._rootNotifier - ) { + if (row._table.options.reactivity?.row === false) { return } - setReactiveProps(row._table._rootNotifier, row, { - skipProperty: skipBaseProperties, + markReactive(row) + setReactiveProps(row._table.get, row, { + skipProperty: getUserSkipPropertyFn( + row._table.options.reactivity?.cell, + skipBaseProperties, + ), }) }, } @@ -120,10 +156,19 @@ export function constructAngularReactivityFeature< export const angularReactivityFeature = constructAngularReactivityFeature() function skipBaseProperties(property: string): boolean { - return property.endsWith('Handler') || !property.startsWith('get') + return ( + // equals `getContext` + property === 'getContext' || + // start with `_` + property[0] === '_' || + // doesn't start with `get`, but faster + !(property[0] === 'g' && property[1] === 'e' && property[2] === 't') || + // ends with `Handler` + property.endsWith('Handler') + ) } -export function setReactiveProps( +function setReactiveProps( notifier: Signal>, obj: { [key: string]: any }, options: { @@ -133,16 +178,17 @@ export function setReactiveProps( const { skipProperty } = options for (const property in obj) { const value = obj[property] - if (typeof value !== 'function') { - continue - } - if (skipProperty(property)) { + if ( + isSignal(value) || + typeof value !== 'function' || + skipProperty(property) + ) { continue } - Object.defineProperty(obj, property, { - enumerable: true, - configurable: false, - value: toComputed(notifier, value, property), + defineLazyComputedProperty(notifier, { + valueFn: value, + property, + originalObject: obj, }) } } diff --git a/packages/angular-table/src/flex-render.ts b/packages/angular-table/src/flex-render.ts index 3799538104..8cf8002ba0 100644 --- a/packages/angular-table/src/flex-render.ts +++ b/packages/angular-table/src/flex-render.ts @@ -2,34 +2,41 @@ import { ChangeDetectorRef, Directive, DoCheck, - effect, - type EffectRef, Inject, - inject, Injector, Input, OnChanges, - runInInjectionContext, SimpleChanges, TemplateRef, Type, ViewContainerRef, + effect, + inject, + runInInjectionContext, } from '@angular/core' +import { memo } from '@tanstack/table-core' import { FlexRenderComponentProps } from './flex-render/context' import { FlexRenderFlags } from './flex-render/flags' import { - flexRenderComponent, FlexRenderComponent, + flexRenderComponent, } from './flex-render/flex-render-component' import { FlexRenderComponentFactory } from './flex-render/flex-render-component-ref' import { FlexRenderComponentView, FlexRenderTemplateView, - type FlexRenderTypedContent, FlexRenderView, mapToFlexRenderTypedContent, } from './flex-render/view' -import { memo } from '@tanstack/table-core' +import type { FlexRenderTypedContent } from './flex-render/view' +import type { + CellContext, + HeaderContext, + Table, + TableFeatures, +} from '@tanstack/table-core' +import type { EffectRef } from '@angular/core' +import { isReactive } from './reactivityUtils' export { injectFlexRenderContext, @@ -51,7 +58,12 @@ export type FlexRenderContent> = standalone: true, providers: [FlexRenderComponentFactory], }) -export class FlexRenderDirective> +export class FlexRenderDirective< + TProps extends + | NonNullable + | CellContext + | HeaderContext, + > implements OnChanges, DoCheck { readonly #flexRenderComponentFactory = inject(FlexRenderComponentFactory) @@ -68,9 +80,13 @@ export class FlexRenderDirective> @Input({ required: true, alias: 'flexRenderProps' }) props: TProps = {} as TProps + @Input({ required: false, alias: 'flexRenderNotifier' }) + notifier: 'doCheck' | 'tableChange' = 'doCheck' + @Input({ required: false, alias: 'flexRenderInjector' }) injector: Injector = inject(Injector) + table: Table renderFlags = FlexRenderFlags.ViewFirstRender renderView: FlexRenderView | null = null @@ -97,7 +113,9 @@ export class FlexRenderDirective> ngOnChanges(changes: SimpleChanges) { if (changes['props']) { + this.table = 'table' in this.props ? this.props.table : null this.renderFlags |= FlexRenderFlags.PropsReferenceChanged + this.bindTableDirtyCheck() } if (changes['content']) { this.renderFlags |= @@ -114,8 +132,13 @@ export class FlexRenderDirective> return } - this.renderFlags |= FlexRenderFlags.DirtyCheck + if (this.notifier === 'doCheck') { + this.renderFlags |= FlexRenderFlags.DirtyCheck + this.doCheck() + } + } + private doCheck() { const latestContent = this.#getContentValue() if (latestContent.kind === 'null' || !this.renderView) { this.renderFlags |= FlexRenderFlags.ContentChanged @@ -129,6 +152,32 @@ export class FlexRenderDirective> this.update() } + #tableChangeEffect: EffectRef | null = null + + private bindTableDirtyCheck() { + this.#tableChangeEffect?.destroy() + this.#tableChangeEffect = null + let firstCheck = !!(this.renderFlags & FlexRenderFlags.ViewFirstRender) + if ( + this.table && + this.notifier === 'tableChange' && + isReactive(this.table) + ) { + this.#tableChangeEffect = effect( + () => { + this.table.get() + if (firstCheck) { + firstCheck = false + return + } + this.renderFlags |= FlexRenderFlags.DirtyCheck + this.doCheck() + }, + { injector: this.injector, forceRoot: true }, + ) + } + } + update() { if ( this.renderFlags & diff --git a/packages/angular-table/src/flex-render/context.ts b/packages/angular-table/src/flex-render/context.ts index b9eacac73c..dcec1e780b 100644 --- a/packages/angular-table/src/flex-render/context.ts +++ b/packages/angular-table/src/flex-render/context.ts @@ -1,4 +1,4 @@ -import { inject, InjectionToken } from '@angular/core' +import { InjectionToken, inject } from '@angular/core' export const FlexRenderComponentProps = new InjectionToken< NonNullable diff --git a/packages/angular-table/src/flex-render/flex-render-component.ts b/packages/angular-table/src/flex-render/flex-render-component.ts index 16335731f0..522dd0b7fe 100644 --- a/packages/angular-table/src/flex-render/flex-render-component.ts +++ b/packages/angular-table/src/flex-render/flex-render-component.ts @@ -93,8 +93,8 @@ export function flexRenderComponent< */ export class FlexRenderComponent { readonly mirror: ComponentMirror - readonly allowedInputNames: string[] = [] - readonly allowedOutputNames: string[] = [] + readonly allowedInputNames: Array = [] + readonly allowedOutputNames: Array = [] constructor( readonly component: Type, diff --git a/packages/angular-table/src/index.ts b/packages/angular-table/src/index.ts index 0e814d7f1a..30cd0760fc 100644 --- a/packages/angular-table/src/index.ts +++ b/packages/angular-table/src/index.ts @@ -4,6 +4,6 @@ export * from './angularReactivityFeature' export * from './createTableHelper' export * from './flex-render' export * from './injectTable' -export * from './lazy-signal-initializer' -export * from './proxy' +export * from './lazySignalInitializer' +export * from './reactivityUtils' export * from './flex-render/flex-render-component' diff --git a/packages/angular-table/src/injectTable.ts b/packages/angular-table/src/injectTable.ts index 9aa78dabd1..55ccf0277e 100644 --- a/packages/angular-table/src/injectTable.ts +++ b/packages/angular-table/src/injectTable.ts @@ -1,13 +1,4 @@ import { computed, signal } from '@angular/core' -import { - constructTable, - coreFeatures, - getInitialTableState, - isFunction, -} from '@tanstack/table-core' -import { lazyInit } from './lazy-signal-initializer' -import { proxifyTable } from './proxy' -import { angularReactivityFeature } from './angularReactivityFeature' import type { RowData, Table, @@ -15,14 +6,19 @@ import type { TableOptions, TableState, } from '@tanstack/table-core' -import type { Signal } from '@angular/core' +import { + constructTable, + coreFeatures, + getInitialTableState, + isFunction, +} from '@tanstack/table-core' +import { lazyInit } from './lazySignalInitializer' +import { angularReactivityFeature } from './angularReactivityFeature' export function injectTable< TFeatures extends TableFeatures, TData extends RowData, ->( - options: () => TableOptions, -): Table & Signal> { +>(options: () => TableOptions): Table { return lazyInit(() => { const features = () => { return { @@ -78,9 +74,8 @@ export function injectTable< }, ) - table._setRootNotifier?.(tableSignal as any) + table._setTableNotifier(tableSignal as any) - // proxify Table instance to provide ability for consumer to listen to any table state changes - return proxifyTable(tableSignal) + return table }) } diff --git a/packages/angular-table/src/lazy-signal-initializer.ts b/packages/angular-table/src/lazySignalInitializer.ts similarity index 100% rename from packages/angular-table/src/lazy-signal-initializer.ts rename to packages/angular-table/src/lazySignalInitializer.ts diff --git a/packages/angular-table/src/proxy.ts b/packages/angular-table/src/proxy.ts deleted file mode 100644 index 1fff8e1c80..0000000000 --- a/packages/angular-table/src/proxy.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { computed, untracked } from '@angular/core' -import type { Signal } from '@angular/core' -import type { RowData, Table, TableFeatures } from '@tanstack/table-core' - -type TableSignal< - TFeatures extends TableFeatures, - TData extends RowData, -> = Table & Signal> - -export function proxifyTable< - TFeatures extends TableFeatures, - TData extends RowData, ->( - tableSignal: Signal>, -): Table & Signal> { - const internalState = tableSignal as TableSignal - - const proxyTargetImplementation = { - default: getDefaultProxyHandler(tableSignal), - experimental: getExperimentalProxyHandler(tableSignal), - } as const - - return new Proxy(internalState, { - apply() { - const signal = untracked(tableSignal) - const impl = signal.options.enableExperimentalReactivity - ? proxyTargetImplementation.experimental - : proxyTargetImplementation.default - return impl.apply() - }, - 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) { - const signal = untracked(tableSignal) - const impl = signal.options.enableExperimentalReactivity - ? proxyTargetImplementation.experimental - : proxyTargetImplementation.default - return impl.has(_, prop) - }, - ownKeys() { - const signal = untracked(tableSignal) - const impl = signal.options.enableExperimentalReactivity - ? proxyTargetImplementation.experimental - : proxyTargetImplementation.default - return impl.ownKeys() - }, - getOwnPropertyDescriptor() { - const signal = untracked(tableSignal) - const impl = signal.options.enableExperimentalReactivity - ? proxyTargetImplementation.experimental - : proxyTargetImplementation.default - return impl.getOwnPropertyDescriptor() - }, - }) -} - -/** - * Here we'll handle all type of accessors: - * - 0 argument -> e.g. table.getCanNextPage()) - * - 0~1 arguments -> e.g. table.getIsSomeRowsPinned(position?) - * - 1 required argument -> e.g. table.getColumn(columnId) - * - 1+ argument -> e.g. table.getRow(id, searchAll?) - * - * Since we are not able to detect automatically the accessors parameters, - * we'll wrap all accessors into a cached function wrapping a computed - * that return it's value based on the given parameters - */ -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() - }, - { debugName }, - ) - } - - const computedCache: Record> = {} - - // 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) - }, - { debugName }, - ) - - computedCache[serializedArgs] = computedSignal - - return computedSignal() - } -} - -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') - // e.g. getCoreRowModel, getSelectedRowModel etc. - // We need that after a signal change even `rowModel` may mark the view as dirty. - // This allows to always get the latest `getContext` value while using flexRender - // && !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/reactivityUtils.ts b/packages/angular-table/src/reactivityUtils.ts new file mode 100644 index 0000000000..03a5205f78 --- /dev/null +++ b/packages/angular-table/src/reactivityUtils.ts @@ -0,0 +1,130 @@ +import { computed } from '@angular/core' +import type { Signal } from '@angular/core' + +export const $TABLE_REACTIVE = Symbol('reactive') + +export function markReactive(obj: T): void { + Object.defineProperty(obj, $TABLE_REACTIVE, { value: true }) +} + +export function isReactive( + obj: T, +): obj is T & { [$TABLE_REACTIVE]: true } { + return Reflect.get(obj, $TABLE_REACTIVE) === true +} + +/** + * Defines a lazy computed property on an object. The property is initialized + * with a getter that computes its value only when accessed for the first time. + * After the first access, the computed value is cached, and the getter is + * replaced with a direct property assignment for efficiency. + * + * @internal should be used only internally + */ +export function defineLazyComputedProperty( + notifier: Signal, + setObjectOptions: { + originalObject: T + property: keyof T & string + valueFn: (...args: any) => any + }, +) { + const { valueFn, originalObject, property } = setObjectOptions + let computedValue: (...args: Array) => any + Object.defineProperty(originalObject, property, { + enumerable: true, + configurable: true, + get() { + computedValue = toComputed(notifier, valueFn, property) + // Once the property is set the first time, we don't need a getter anymore + // since we have a computed / cached fn value + Object.defineProperty(this, property, { + value: computedValue, + configurable: true, + enumerable: true, + }) + return computedValue + }, + }) +} + +/** + * @internal should be used only internally + */ +type ComputedFunction = + // 0 args + T extends (...args: []) => infer TReturn + ? Signal + : // 1+ args + T extends (arg0?: any, ...args: Array) => any + ? T + : never + +/** + * @description Transform a function into a computed that react to given notifier re-computations + * + * Here we'll handle all type of accessors: + * - 0 argument -> e.g. table.getCanNextPage()) + * - 0~1 arguments -> e.g. table.getIsSomeRowsPinned(position?) + * - 1 required argument -> e.g. table.getColumn(columnId) + * - 1+ argument -> e.g. table.getRow(id, searchAll?) + * + * Since we are not able to detect automatically the accessors parameters, + * we'll wrap all accessors into a cached function wrapping a computed + * that return it's value based on the given parameters + * + * @internal should be used only internally + */ +export function toComputed< + T, + TReturn, + TFunction extends (...args: any) => TReturn, +>( + notifier: Signal, + fn: TFunction, + debugName: string, +): ComputedFunction { + const hasArgs = fn.length > 0 + if (!hasArgs) { + const computedFn = computed( + () => { + void notifier() + return fn() + }, + { debugName }, + ) + Object.defineProperty(computedFn, 'name', { value: debugName }) + markReactive(computedFn) + return computedFn as ComputedFunction + } + + const computedCache: Record> = {} + + const computedFn = (arg0: any, ...otherArgs: Array) => { + const argsArray = [arg0, ...otherArgs] + const serializedArgs = serializeArgs(...argsArray) + if (computedCache.hasOwnProperty(serializedArgs)) { + return computedCache[serializedArgs]?.() + } + const computedSignal = computed( + () => { + void notifier() + return fn(...argsArray) + }, + { debugName }, + ) + + computedCache[serializedArgs] = computedSignal + + return computedSignal() + } + + Object.defineProperty(computedFn, 'name', { value: debugName }) + markReactive(computedFn) + + return computedFn as ComputedFunction +} + +function serializeArgs(...args: Array) { + return JSON.stringify(args) +} diff --git a/packages/angular-table/tests/benchmarks/injectTable.benchmark.ts b/packages/angular-table/tests/benchmarks/injectTable.benchmark.ts new file mode 100644 index 0000000000..78e24cd7d4 --- /dev/null +++ b/packages/angular-table/tests/benchmarks/injectTable.benchmark.ts @@ -0,0 +1,35 @@ +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 new file mode 100644 index 0000000000..00fbe06dc4 --- /dev/null +++ b/packages/angular-table/tests/benchmarks/setup.ts @@ -0,0 +1,60 @@ +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/angular-table/tests/flex-render-component.test-d.ts b/packages/angular-table/tests/flex-render/flex-render-component.test-d.ts similarity index 93% rename from packages/angular-table/tests/flex-render-component.test-d.ts rename to packages/angular-table/tests/flex-render/flex-render-component.test-d.ts index dca2c7b989..9624169f2c 100644 --- a/packages/angular-table/tests/flex-render-component.test-d.ts +++ b/packages/angular-table/tests/flex-render/flex-render-component.test-d.ts @@ -1,6 +1,6 @@ import { input } from '@angular/core' import { test } from 'vitest' -import { flexRenderComponent } from '../src' +import { flexRenderComponent } from '../../src' test('Infer component inputs', () => { class Test { diff --git a/packages/angular-table/tests/flex-render-table.test.ts b/packages/angular-table/tests/flex-render/flex-render-table.test.ts similarity index 99% rename from packages/angular-table/tests/flex-render-table.test.ts rename to packages/angular-table/tests/flex-render/flex-render-table.test.ts index 360f0cd350..09bde23aa2 100644 --- a/packages/angular-table/tests/flex-render-table.test.ts +++ b/packages/angular-table/tests/flex-render/flex-render-table.test.ts @@ -19,8 +19,8 @@ import { flexRenderComponent, injectFlexRenderContext, injectTable, -} from '../src' -import type { FlexRenderContent } from '../src' +} from '../../src' +import type { FlexRenderContent } from '../../src' import type { CellContext, ExpandedState, diff --git a/packages/angular-table/tests/flex-render.test.ts b/packages/angular-table/tests/flex-render/flex-render.unit.test.ts similarity index 93% rename from packages/angular-table/tests/flex-render.test.ts rename to packages/angular-table/tests/flex-render/flex-render.unit.test.ts index b226780802..13a4783606 100644 --- a/packages/angular-table/tests/flex-render.test.ts +++ b/packages/angular-table/tests/flex-render/flex-render.unit.test.ts @@ -1,13 +1,15 @@ -import { Component, input, type TemplateRef, ViewChild } from '@angular/core' -import { type ComponentFixture, TestBed } from '@angular/core/testing' +import { Component, ViewChild, input } from '@angular/core' +import { TestBed } from '@angular/core/testing' import { createColumnHelper } from '@tanstack/table-core' import { describe, expect, test } from 'vitest' import { FlexRenderDirective, injectFlexRenderContext, -} from '../src/flex-render' -import { setFixtureSignalInput, setFixtureSignalInputs } from './test-utils' -import { flexRenderComponent } from '../src/flex-render/flex-render-component' +} from '../../src/flex-render' +import { setFixtureSignalInput, setFixtureSignalInputs } from '../test-utils' +import { flexRenderComponent } from '../../src/flex-render/flex-render-component' +import type { TemplateRef } from '@angular/core' +import type { ComponentFixture } from '@angular/core/testing' interface Data { id: string diff --git a/packages/angular-table/tests/injectTable.test.ts b/packages/angular-table/tests/injectTable.test.ts index b80fc70c98..2246d63874 100644 --- a/packages/angular-table/tests/injectTable.test.ts +++ b/packages/angular-table/tests/injectTable.test.ts @@ -1,12 +1,6 @@ +import { isProxy } from 'node:util/types' import { describe, expect, test, vi } from 'vitest' -import { - Component, - effect, - input, - isSignal, - signal, - untracked, -} from '@angular/core' +import { Component, effect, input, isSignal, signal } from '@angular/core' import { TestBed } from '@angular/core/testing' import { ColumnDef, @@ -14,13 +8,12 @@ import { createPaginatedRowModel, stockFeatures, } from '@tanstack/table-core' -import { injectTable } from '../src/injectTable' +import { RowModel, injectTable } from '../src' import { - experimentalReactivity_testShouldBeComputedProperty, setFixtureSignalInputs, testShouldBeComputedProperty, } from './test-utils' -import { type PaginationState, RowModel } from '../src' +import type { PaginationState } from '../src' describe('injectTable', () => { test('should render with required signal inputs', () => { @@ -60,10 +53,10 @@ describe('injectTable', () => { columns: columns, getRowId: (row) => row.id, })) - const tablePropertyKeys = Object.keys(table()) + const tablePropertyKeys = Object.keys(table.get()) test('table must be a signal', () => { - expect(isSignal(table)).toEqual(true) + expect(isSignal(table.get)).toEqual(true) }) test('supports "in" operator', () => { @@ -73,20 +66,10 @@ describe('injectTable', () => { }) test('supports "Object.keys"', () => { - const keys = Object.keys(table()) + const keys = Object.keys(table.get()) expect(Object.keys(table)).toEqual(keys) }) - test.each( - tablePropertyKeys.map((property) => [ - property, - testShouldBeComputedProperty(untracked(table), property), - ]), - )('property (%s) is computed -> (%s)', (name, expected) => { - const tableProperty = table[name as keyof typeof table] - expect(isSignal(tableProperty)).toEqual(expected) - }) - test('Row model is reactive', () => { const coreRowModelFn = vi.fn<(model: RowModel) => void>() @@ -154,12 +137,16 @@ describe('injectTable - Experimental reactivity', () => { columns: columns, getRowId: (row) => row.id, enableExperimentalReactivity: true, + enableColumnAutoReactivity: true, + enableCellAutoReactivity: true, + enableRowAutoReactivity: true, + enableHeaderAutoReactivity: true, })) const tablePropertyKeys = Object.keys(table) describe('Proxy', () => { - test('table must be a signal', () => { - expect(isSignal(table)).toEqual(true) + test('table is proxy', () => { + expect(isProxy(table)).toBe(true) }) test('supports "in" operator', () => { @@ -172,13 +159,18 @@ describe('injectTable - Experimental reactivity', () => { const keys = Object.keys(table) expect(Object.keys(table)).toEqual(keys) }) + + test('supports "Object.has"', () => { + 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), + testShouldBeComputedProperty(table, property), ]), )('property (%s) is computed -> (%s)', (name, expected) => { const tableProperty = table[name as keyof typeof table] @@ -193,10 +185,7 @@ describe('injectTable - Experimental reactivity', () => { test.each( headerPropertyKeys.map((property) => [ property, - experimentalReactivity_testShouldBeComputedProperty( - headerGroup, - property, - ), + testShouldBeComputedProperty(headerGroup, property), ]), )( `HeaderGroup ${headerGroup.id} (${index}) - property (%s) is computed -> (%s)`, @@ -212,10 +201,7 @@ describe('injectTable - Experimental reactivity', () => { test.each( headerPropertyKeys.map((property) => [ property, - experimentalReactivity_testShouldBeComputedProperty( - header, - property, - ), + testShouldBeComputedProperty(header, property), ]), )( `HeaderGroup ${headerGroup.id} (${index}) / Header ${header.id} - property (%s) is computed -> (%s)`, @@ -235,7 +221,7 @@ describe('injectTable - Experimental reactivity', () => { test.each( columnPropertyKeys.map((property) => [ property, - experimentalReactivity_testShouldBeComputedProperty(column, property), + testShouldBeComputedProperty(column, property), ]), )( `Column ${column.id} (${index}) - property (%s) is computed -> (%s)`, @@ -254,7 +240,7 @@ describe('injectTable - Experimental reactivity', () => { test.each( rowsPropertyKeys.map((property) => [ property, - experimentalReactivity_testShouldBeComputedProperty(row, property), + testShouldBeComputedProperty(row, property), ]), )( `Row ${row.id} (${index}) - property (%s) is computed -> (%s)`, @@ -270,7 +256,7 @@ describe('injectTable - Experimental reactivity', () => { test.each( cellPropertyKeys.map((property) => [ property, - experimentalReactivity_testShouldBeComputedProperty(cell, property), + testShouldBeComputedProperty(cell, property), ]), )( `Row ${row.id} (${index}) / Cell ${cell.id} - property (%s) is computed -> (%s)`, diff --git a/packages/angular-table/tests/reactivityUtils.test.ts b/packages/angular-table/tests/reactivityUtils.test.ts new file mode 100644 index 0000000000..72c3a5110b --- /dev/null +++ b/packages/angular-table/tests/reactivityUtils.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, test, vi } from 'vitest' +import { effect, isSignal, signal } from '@angular/core' +import { TestBed } from '@angular/core/testing' +import { defineLazyComputedProperty, toComputed } from '../src/reactivityUtils' + +describe('toComputed', () => { + describe('args = 0', () => { + test('creates a computed', () => { + const notifier = signal(1) + + const result = toComputed( + notifier, + () => { + return notifier() * 2 + }, + 'double', + ) + + expect(result.name).toEqual('double') + expect(isSignal(result)).toEqual(true) + + TestBed.runInInjectionContext(() => { + const mockFn = vi.fn() + + effect(() => { + mockFn(result()) + }) + + TestBed.flushEffects() + expect(mockFn).toHaveBeenLastCalledWith(2) + + notifier.set(3) + TestBed.flushEffects() + expect(mockFn).toHaveBeenLastCalledWith(6) + + notifier.set(2) + TestBed.flushEffects() + expect(mockFn).toHaveBeenLastCalledWith(4) + + expect(mockFn.mock.calls.length).toEqual(3) + }) + }) + }) + + describe('args >= 1', () => { + test('creates a fn an explicit first argument and allows other args', () => { + const notifier = signal(1) + + const fn1 = toComputed( + notifier, + (arg0: number, arg1: string, arg3?: number) => { + return { arg0, arg1, arg3 } + }, + '3args', + ) + expect(fn1.length).toEqual(1) + + // currently full rest parameters is not supported + const fn2 = toComputed( + notifier, + function myFn(...args: Array) { + return args + }, + '3args', + ) + expect(fn2.length).toEqual(0) + }) + + test('reuse created computed when args are the same', () => { + const notifier = signal(1) + + const invokeMock = vi.fn() + + const sum = toComputed( + notifier, + (arg0: number, arg1?: string) => { + invokeMock(arg0) + return notifier() + arg0 + }, + 'sum', + ) + + sum(1) + sum(3) + sum(2) + sum(1) + sum(1) + sum(2) + sum(3) + + expect(invokeMock).toHaveBeenCalledTimes(3) + expect(invokeMock).toHaveBeenNthCalledWith(1, 1) + expect(invokeMock).toHaveBeenNthCalledWith(2, 3) + expect(invokeMock).toHaveBeenNthCalledWith(3, 2) + }) + + test('cached computed are reactive', () => { + const invokeMock = vi.fn() + const notifier = signal(1) + + const sum = toComputed( + notifier, + (arg0: number) => { + invokeMock(arg0) + return notifier() + arg0 + }, + 'sum', + ) + + TestBed.runInInjectionContext(() => { + const mockSumBy3Fn = vi.fn() + const mockSumBy2Fn = vi.fn() + + effect(() => { + mockSumBy3Fn(sum(3)) + }) + effect(() => { + mockSumBy2Fn(sum(2)) + }) + + TestBed.flushEffects() + expect(mockSumBy3Fn).toHaveBeenLastCalledWith(4) + expect(mockSumBy2Fn).toHaveBeenLastCalledWith(3) + + notifier.set(2) + TestBed.flushEffects() + expect(mockSumBy3Fn).toHaveBeenLastCalledWith(5) + expect(mockSumBy2Fn).toHaveBeenLastCalledWith(4) + + expect(mockSumBy3Fn.mock.calls.length).toEqual(2) + expect(mockSumBy2Fn.mock.calls.length).toEqual(2) + }) + + for (let i = 0; i < 4; i++) { + sum(3) + sum(2) + } + // invoked every time notifier change + expect(invokeMock).toHaveBeenCalledTimes(4) + }) + }) + + describe('args 0~1', () => { + test('creates a fn an explicit first argument and allows other args', () => { + const notifier = signal(1) + + const fn1 = toComputed( + notifier, + (arg0?: number) => { + if (arg0 === undefined) { + return 5 * notifier() + } + return arg0 * notifier() + }, + 'optionalArgs', + ) + expect(fn1.length).toEqual(1) + + fn1() + }) + }) +}) + +describe('defineLazyComputedProperty', () => { + test('define a computed property and cache the result after first access', () => { + const notifier = signal(1) + const originalObject = {} as any + const mockValueFn = vi.fn(() => 2) + + defineLazyComputedProperty(notifier, { + originalObject, + property: 'computedProp', + valueFn: mockValueFn, + }) + + let propDescriptor = Object.getOwnPropertyDescriptor( + originalObject, + 'computedProp', + ) + expect(propDescriptor && !!propDescriptor.get).toEqual(true) + + originalObject.computedProp + + propDescriptor = Object.getOwnPropertyDescriptor( + originalObject, + 'computedProp', + ) + expect(propDescriptor!.get).not.toBeDefined() + expect(isSignal(propDescriptor!.value)) + }) +}) diff --git a/packages/angular-table/tests/test-utils.ts b/packages/angular-table/tests/test-utils.ts index 5dd8c53191..a7f2ee7bd4 100644 --- a/packages/angular-table/tests/test-utils.ts +++ b/packages/angular-table/tests/test-utils.ts @@ -1,4 +1,4 @@ -import { SIGNAL, signalSetFn } from '@angular/core/primitives/signals' +import { SIGNAL } from '@angular/core/primitives/signals' import type { InputSignal } from '@angular/core' import type { ComponentFixture } from '@angular/core/testing' @@ -48,41 +48,22 @@ export async function flushQueue() { await new Promise(setImmediate) } -export const experimentalReactivity_testShouldBeComputedProperty = ( +const staticComputedProperties = ['get'] +export const testShouldBeComputedProperty = ( testObj: any, propertyName: string, ) => { - if (propertyName.startsWith('_rootNotifier')) { + if (staticComputedProperties.some((prop) => propertyName === prop)) { return true } if (propertyName.endsWith('Handler')) { return false } - if (propertyName.startsWith('get')) { // Only properties with no arguments are computed const fn = testObj[propertyName] // Cannot test if is lazy computed since we return the unwrapped value return fn instanceof Function && fn.length === 0 } - - return false -} - -export const testShouldBeComputedProperty = ( - testObj: any, - propertyName: string, -) => { - if (propertyName.endsWith('Handler')) { - return false - } - - if (propertyName.startsWith('get')) { - // Only properties with no arguments are computed - const fn = testObj[propertyName] - // Cannot test if is lazy computed since we return the unwrapped value - return fn instanceof Function && fn.length === 0 - } - return false }