diff --git a/examples/angular/row-dnd/tsconfig.json b/examples/angular/row-dnd/tsconfig.json index b58d3efc71..eb0dd3b6d5 100644 --- a/examples/angular/row-dnd/tsconfig.json +++ b/examples/angular/row-dnd/tsconfig.json @@ -15,7 +15,7 @@ "sourceMap": true, "declaration": false, "experimentalDecorators": true, - "moduleResolution": "node", + "moduleResolution": "bundler", "importHelpers": true, "target": "ES2022", "module": "ES2022", diff --git a/packages/angular-table/package.json b/packages/angular-table/package.json index 23793f3950..bea8f023ba 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 c8acfffede..ffbbd4e54f 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,32 @@ 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 +} + interface TableOptions_AngularReactivity { enableExperimentalReactivity?: boolean + reactivity?: Partial } interface Table_AngularReactivity< TFeatures extends TableFeatures, TData extends RowData, > { - _rootNotifier?: Signal> - _setRootNotifier?: (signal: Signal>) => void + /** + * Returns a table signal that updates whenever the table state or options changes. + */ + get: Signal> + /** + * @internal + */ + setTableNotifier: (signal: Signal>) => void } interface AngularReactivityFeatureConstructors< @@ -40,75 +56,96 @@ 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, + }, + } }, 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) - } - - // Set reactive props on table instance - setReactiveProps(table._rootNotifier!, table, { + table.setTableNotifier = (notifier) => rootNotifier.set(notifier) + table.get = computed(() => rootNotifier()!(), { equal: () => false }) + markReactive(table) + setReactiveProps(table.get, table, { + overridePrototype: false, skipProperty: skipBaseProperties, }) }, assignCellPrototype: (prototype, table) => { - if (!table.options.enableExperimentalReactivity) { + if (table.options.reactivity?.cell === false) { return } - // Store reference to table for runtime access - ;(prototype as any).__angularTable = table - setReactivePropsOnPrototype(prototype, { - skipProperty: skipBaseProperties, + markReactive(prototype) + setReactiveProps(table.get, prototype, { + skipProperty: getUserSkipPropertyFn( + table.options.reactivity?.cell, + skipBaseProperties, + ), + overridePrototype: true, }) }, assignColumnPrototype: (prototype, table) => { - if (!table.options.enableExperimentalReactivity) { + if (table.options.reactivity?.column === false) { return } - // Store reference to table for runtime access - ;(prototype as any).__angularTable = table - setReactivePropsOnPrototype(prototype, { - skipProperty: skipBaseProperties, + markReactive(prototype) + setReactiveProps(table.get, prototype, { + skipProperty: getUserSkipPropertyFn( + table.options.reactivity?.cell, + skipBaseProperties, + ), + overridePrototype: true, }) }, assignHeaderPrototype: (prototype, table) => { - if (!table.options.enableExperimentalReactivity) { + if (table.options.reactivity?.header === false) { return } - // Store reference to table for runtime access - ;(prototype as any).__angularTable = table - setReactivePropsOnPrototype(prototype, { - skipProperty: skipBaseProperties, + markReactive(prototype) + setReactiveProps(table.get, prototype, { + skipProperty: getUserSkipPropertyFn( + table.options.reactivity?.cell, + skipBaseProperties, + ), + overridePrototype: true, }) }, assignRowPrototype: (prototype, table) => { - if (!table.options.enableExperimentalReactivity) { + if (table.options.reactivity?.row === false) { return } - // Store reference to table for runtime access - ;(prototype as any).__angularTable = table - setReactivePropsOnPrototype(prototype, { - skipProperty: skipBaseProperties, + markReactive(prototype) + setReactiveProps(table.get, prototype, { + skipProperty: getUserSkipPropertyFn( + table.options.reactivity?.cell, + skipBaseProperties, + ), + overridePrototype: true, }) }, } @@ -117,101 +154,42 @@ 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: { + overridePrototype?: boolean skipProperty: (property: string) => boolean }, ) { 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, + overridePrototype: options.overridePrototype, }) } } - -function setReactivePropsOnPrototype( - prototype: Record, - options: { - skipProperty: (property: string) => boolean - }, -) { - const { skipProperty } = options - - // Wrap methods on the prototype that will be lazily wrapped at instance access time - // We intercept property access on the prototype to wrap methods when they're first accessed - const propertyNames = Object.getOwnPropertyNames(prototype) - for (const property of propertyNames) { - if (property === 'table' || property.startsWith('__angular')) { - continue - } - const descriptor = Object.getOwnPropertyDescriptor(prototype, property) - if (descriptor && typeof descriptor.value === 'function') { - if (skipProperty(property)) { - continue - } - // Store original method - const originalMethod = descriptor.value - // Replace with a function that will be wrapped at instance creation time - Object.defineProperty(prototype, property, { - enumerable: descriptor.enumerable, - configurable: descriptor.configurable, - value: function (this: any, ...args: Array) { - // Get the table from the instance - const table = this.table - if (table && table._rootNotifier) { - // Check if already wrapped on this instance - const instanceDescriptor = Object.getOwnPropertyDescriptor( - this, - property, - ) - if ( - instanceDescriptor && - instanceDescriptor.value?.__angularWrapped - ) { - return instanceDescriptor.value.apply(this, args) - } - // Wrap the method with toComputed using the table's rootNotifier - // Create a wrapper function that calls the original method - const boundMethod = originalMethod.bind(this) - const wrapped = toComputed( - table._rootNotifier, - boundMethod, - property, - ) as any - wrapped.__angularWrapped = true - // Cache the wrapped version on the instance - Object.defineProperty(this, property, { - enumerable: true, - configurable: true, - value: wrapped, - }) - // Call the wrapped function with args - if (args.length === 0) { - return wrapped() - } else if (args.length === 1) { - return wrapped(args[0]) - } else { - return wrapped(args[0], ...args.slice(1)) - } - } - return originalMethod.apply(this, args) - }, - }) - } - } -} diff --git a/packages/angular-table/src/createTableHelper.ts b/packages/angular-table/src/createTableHelper.ts index 12d98536f0..dc1414e1d7 100644 --- a/packages/angular-table/src/createTableHelper.ts +++ b/packages/angular-table/src/createTableHelper.ts @@ -13,7 +13,7 @@ import type { export type TableHelper< TFeatures extends TableFeatures, TData extends RowData = any, -> = Omit, 'tableCreator'> & { +> = Omit, 'tableCreator'> & { injectTable: ( tableOptions: () => Omit< TableOptions, @@ -30,10 +30,7 @@ export function createTableHelper< tableHelperOptions: TableHelperOptions, ): TableHelper { const tableHelper = constructTableHelper( - injectTable as unknown as ( - tableOptions: () => TableOptions, - selector?: (state: TableState) => any, - ) => AngularTable, + injectTable as unknown as any, tableHelperOptions, ) return { diff --git a/packages/angular-table/src/flex-render.ts b/packages/angular-table/src/flex-render.ts index e34a8b3d24..5660bd14fe 100644 --- a/packages/angular-table/src/flex-render.ts +++ b/packages/angular-table/src/flex-render.ts @@ -28,8 +28,15 @@ import { FlexRenderView, mapToFlexRenderTypedContent, } from './flex-render/view' -import type { EffectRef } from '@angular/core' +import { isReactive } from './reactivityUtils' import type { FlexRenderTypedContent } from './flex-render/view' +import type { + CellContext, + HeaderContext, + Table, + TableFeatures, +} from '@tanstack/table-core' +import type { EffectRef } from '@angular/core' export { injectFlexRenderContext, @@ -51,7 +58,12 @@ export type FlexRenderContent> = standalone: true, providers: [FlexRenderComponentFactory], }) -export class FlexRender> +export class FlexRender< + TProps extends + | NonNullable + | CellContext + | HeaderContext, +> implements OnChanges, DoCheck { readonly #flexRenderComponentFactory = inject(FlexRenderComponentFactory) @@ -68,9 +80,13 @@ export class FlexRender> @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 FlexRender> 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 FlexRender> 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 FlexRender> 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 }, + ) + } + } + update() { if ( this.renderFlags & @@ -284,4 +333,7 @@ export class FlexRender> } } -export { FlexRender as FlexRenderDirective } +/** + * @deprecated Use `FlexRender` import instead. + */ +export const FlexRenderDirective = FlexRender 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 092074378a..9c3bf7e99f 100644 --- a/packages/angular-table/src/injectTable.ts +++ b/packages/angular-table/src/injectTable.ts @@ -2,13 +2,15 @@ import { Injector, assertInInjectionContext, computed, + effect, inject, + untracked, } from '@angular/core' import { constructTable } from '@tanstack/table-core' import { injectStore } from '@tanstack/angular-store' -import { lazyInit } from './lazy-signal-initializer' -import { proxifyTable } from './proxy' +import { lazyInit } from './lazySignalInitializer' import { angularReactivityFeature } from './angularReactivityFeature' +import type { Signal } from '@angular/core' import type { RowData, Table, @@ -16,26 +18,24 @@ import type { TableOptions, TableState, } from '@tanstack/table-core' -import type { Signal } from '@angular/core' export type AngularTable< TFeatures extends TableFeatures, TData extends RowData, TSelected = {}, -> = Table & - Signal> & { - /** - * The selected state from the table store, based on the selector provided. - */ - readonly state: Signal> - /** - * Subscribe to changes in the table store with a custom selector. - */ - Subscribe: (props: { - selector: (state: TableState) => TSubSelected - children: ((state: Signal>) => any) | any - }) => any - } +> = Table & { + /** + * The selected state from the table store, based on the selector provided. + */ + readonly state: Signal> + /** + * Subscribe to changes in the table store with a custom selector. + */ + Subscribe: (props: { + selector: (state: TableState) => TSubSelected + children: ((state: Signal>) => any) | any + }) => any +} export function injectTable< TFeatures extends TableFeatures, @@ -58,110 +58,72 @@ export function injectTable< }, } as TableOptions - const table = constructTable(resolvedOptions) + const table = constructTable(resolvedOptions) as AngularTable< + TFeatures, + TData, + TSelected + > - // Compose table options using computed. - // This is to allow `tableSignal` to listen and set table option const updatedOptions = computed>(() => { - // listen to input options changed const tableOptionsValue = options() - const result: TableOptions = { ...table.options, - ...resolvedOptions, ...tableOptionsValue, _features: { ...tableOptionsValue._features, angularReactivityFeature, }, } - - // Store handles state internally, but allow controlled state to be passed - const tableOptionsAny = tableOptionsValue as any - if (tableOptionsAny.state) { - ;(result as any).state = tableOptionsAny.state - } - if (tableOptionsAny.onStateChange) { - ;(result as any).onStateChange = tableOptionsAny.onStateChange + if (tableOptionsValue.state) { + result.state = tableOptionsValue.state } - return result }) - // convert table instance to signal for proxify to listen to any table state and options changes - const tableSignal = computed( - () => { - table.setOptions(updatedOptions()) - return table - }, - { - equal: () => false, + effect( + (onCleanup) => { + const cleanup = table.store.mount() + onCleanup(() => cleanup()) }, + { injector }, ) - table._setRootNotifier?.(tableSignal as any) - - // Wrap all "get*" methods to make them reactive (only for non-experimental mode) - const allState = injectStore( + const tableState = injectStore( table.store, (state: TableState) => state, + { injector }, ) - // Only apply manual reactivity wrapper if experimental reactivity is disabled - if (!table.options.enableExperimentalReactivity) { - Object.keys(table).forEach((key) => { - const value = (table as any)[key] - if (typeof value === 'function' && key.startsWith('get')) { - const originalMethod = value.bind(table) - if (originalMethod.length === 0 && !key.endsWith('Handler')) { - // Methods with no arguments (except handlers) become computed signals - ;(table as any)[key] = computed(() => { - // Access state to create reactive dependency - allState() - return originalMethod() - }) - } else { - // Methods with arguments or handlers stay as functions but still track state - ;(table as any)[key] = (...args: Array) => { - // Access state to create reactive dependency - allState() - return originalMethod(...args) - } - } - } - }) - } + const tableSignalNotifier = computed( + () => { + // TODO: replace computed just using effects could be better? + tableState() + table.setOptions(updatedOptions()) + untracked(() => { + table.baseStore.setState((prev) => ({ ...prev })) + }) + return table + }, + { equal: () => false }, + ) - // Add Subscribe function - ;(table as any).Subscribe = function Subscribe(props: { + table.setTableNotifier(tableSignalNotifier) + + table.Subscribe = function Subscribe(props: { selector: (state: TableState) => TSubSelected children: ((state: Signal>) => any) | any }) { - const selected = injectStore(table.store, props.selector) + const selected = injectStore(table.store, props.selector, { injector }) if (typeof props.children === 'function') { return props.children(selected) } return props.children } - const stateStore = injectStore(table.store, selector) - - // proxify Table instance to provide ability for consumer to listen to any table state changes - const proxifiedTable = proxifyTable(tableSignal) as AngularTable< - TFeatures, - TData, - TSelected - > + const stateStore = injectStore(table.store, selector, { injector }) - // Add state property - Object.defineProperty(proxifiedTable, 'state', { - get() { - return stateStore - }, - enumerable: true, - configurable: true, - }) + Reflect.set(table, 'state', stateStore) - return proxifiedTable - }, injector) + return table + }) } diff --git a/packages/angular-table/src/lazy-signal-initializer.ts b/packages/angular-table/src/lazySignalInitializer.ts similarity index 76% rename from packages/angular-table/src/lazy-signal-initializer.ts rename to packages/angular-table/src/lazySignalInitializer.ts index b36c592634..92f8dcc901 100644 --- a/packages/angular-table/src/lazy-signal-initializer.ts +++ b/packages/angular-table/src/lazySignalInitializer.ts @@ -1,21 +1,15 @@ -import { runInInjectionContext, untracked } from '@angular/core' -import type { Injector } from '@angular/core' +import { untracked } from '@angular/core' /** * Implementation from @tanstack/angular-query * {https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/util/lazy-init/lazy-init.ts} */ -export function lazyInit( - initializer: () => T, - injector?: Injector, -): T { +export function lazyInit(initializer: () => T): T { let object: T | null = null const initializeObject = () => { if (!object) { - object = untracked(() => - injector ? runInInjectionContext(injector, initializer) : initializer(), - ) + object = untracked(() => initializer()) } } 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..eb02ce9906 --- /dev/null +++ b/packages/angular-table/src/reactivityUtils.ts @@ -0,0 +1,169 @@ +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 + overridePrototype?: boolean + }, +) { + const { originalObject, property, overridePrototype, valueFn } = + setObjectOptions + + if (overridePrototype) { + assignReactivePrototypeAPI(notifier, originalObject, property) + } else { + Object.defineProperty(originalObject, property, { + enumerable: true, + configurable: true, + get(this) { + const computedValue = toComputed(notifier, valueFn, property) + markReactive(computedValue) + // 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) +} + +export function assignReactivePrototypeAPI( + notifier: Signal, + prototype: Record, + fnName: string, +) { + const fn = prototype[fnName] + const originalArgsLength = Math.max( + 0, + Reflect.get(fn, 'originalArgsLength') ?? 0, + ) + + if (originalArgsLength <= 1) { + const cached = {} as Record> + Object.defineProperty(prototype, fnName, { + enumerable: true, + configurable: true, + get(this) { + const self = this + return (cached[`${self.id}_${fnName}`] ??= computed(() => { + notifier() + return fn.call(self) + })) + }, + }) + } else { + prototype[fnName] = function (this: unknown, ...args: Array) { + notifier() + return fn.apply(this, 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 93% 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 74fa42510b..31767aecf1 100644 --- a/packages/angular-table/tests/flex-render-table.test.ts +++ b/packages/angular-table/tests/flex-render/flex-render-table.test.ts @@ -19,13 +19,12 @@ import { flexRenderComponent, injectFlexRenderContext, injectTable, -} from '../src' -import type { FlexRenderContent } from '../src' +} from '../../src' +import type { FlexRenderContent } from '../../src' import type { CellContext, ExpandedState, TableOptions, - TableState, } from '@tanstack/table-core' import type { TemplateRef } from '@angular/core' @@ -142,7 +141,7 @@ describe('FlexRenderDirective', () => { expect(firstCell!.textContent).toEqual('Initial status') statusComponent.set(null) - fixture.detectChanges() + await fixture.whenRenderingDone() expect(firstCell!.matches(':empty')).toBe(true) statusComponent.set( @@ -150,7 +149,8 @@ describe('FlexRenderDirective', () => { inputs: { status: 'Updated status' }, }), ) - fixture.detectChanges() + await fixture.whenRenderingDone() + const el = firstCell!.firstElementChild as HTMLElement expect(el.tagName).toEqual('APP-TEST-BADGE') expect(el.textContent).toEqual('Updated status') @@ -204,11 +204,14 @@ describe('FlexRenderDirective', () => { }) test('Support cell with component output', async () => { + const callExpandRender = vi.fn<(val: boolean) => void>() + const columns = [ { id: 'expand', header: 'Expand', cell: ({ row }: any) => { + callExpandRender(row.getIsExpanded()) return flexRenderComponent(ExpandCell, { inputs: { expanded: row.getIsExpanded() }, outputs: { toggleExpand: () => row.toggleExpanded() }, @@ -297,6 +300,17 @@ describe('FlexRenderDirective', () => { '0': true, }) fixture.detectChanges() + + // TODO: As a perf improvement / better maintenability, + // check in a future if we can avoid evaluating the cell twice during the first render. done during comparison + expect(callExpandRender).toHaveBeenCalledTimes(5) + expect(callExpandRender).toHaveBeenNthCalledWith(1, false) + expect(callExpandRender).toHaveBeenNthCalledWith(2, false) + expect(callExpandRender).toHaveBeenNthCalledWith(3, true) + // TODO: fix. caused by running the content(props) in a effect on flex-render.ts#226 + expect(callExpandRender).toHaveBeenNthCalledWith(4, true) + expect(callExpandRender).toHaveBeenNthCalledWith(5, true) + expect(buttonEl.nativeElement.innerHTML).toEqual(' Expanded ') }) }) 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 9991562c92..abc4995b34 100644 --- a/packages/angular-table/tests/injectTable.test.ts +++ b/packages/angular-table/tests/injectTable.test.ts @@ -1,23 +1,14 @@ +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, - createCoreRowModel, createPaginatedRowModel, stockFeatures, } from '@tanstack/table-core' -import { injectTable } from '../src/injectTable' -import { RowModel } from '../src' +import { RowModel, injectTable } from '../src' import { - experimentalReactivity_testShouldBeComputedProperty, setFixtureSignalInputs, testShouldBeComputedProperty, } from './test-utils' @@ -64,10 +55,8 @@ describe('injectTable', () => { })), ) - const tablePropertyKeys = Object.keys(table()) - test('table must be a signal', () => { - expect(isSignal(table)).toEqual(true) + expect(isSignal(table.get)).toEqual(true) }) test('supports "in" operator', () => { @@ -77,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>() @@ -111,7 +90,6 @@ describe('injectTable', () => { columns: columns, _features: stockFeatures, _rowModels: { - coreRowModel: createCoreRowModel(), paginatedRowModel: createPaginatedRowModel(), }, getRowId: (row) => row.id, @@ -133,14 +111,14 @@ describe('injectTable', () => { pagination.set({ pageIndex: 0, pageSize: 3 }) TestBed.tick() - }) - expect(coreRowModelFn).toHaveBeenCalledOnce() - expect(coreRowModelFn.mock.calls[0]![0].rows.length).toEqual(10) + expect(coreRowModelFn).toHaveBeenCalledOnce() + expect(coreRowModelFn.mock.calls[0]![0].rows.length).toEqual(10) - expect(rowModelFn).toHaveBeenCalledTimes(2) - expect(rowModelFn.mock.calls[0]![0].rows.length).toEqual(5) - expect(rowModelFn.mock.calls[1]![0].rows.length).toEqual(3) + expect(rowModelFn).toHaveBeenCalledTimes(2) + expect(rowModelFn.mock.calls[0]![0].rows.length).toEqual(5) + expect(rowModelFn.mock.calls[1]![0].rows.length).toEqual(3) + }) }) }) }) @@ -158,14 +136,19 @@ describe('injectTable - Experimental reactivity', () => { _features: { ...stockFeatures }, columns: columns, getRowId: (row) => row.id, - enableExperimentalReactivity: true, + reactivity: { + column: true, + cell: true, + row: true, + header: 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', () => { @@ -178,13 +161,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] @@ -199,10 +187,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)`, @@ -218,10 +203,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)`, @@ -241,7 +223,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)`, @@ -260,7 +242,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)`, @@ -276,7 +258,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/lazy-init.test.ts b/packages/angular-table/tests/lazy-init.test.ts index 0806e12cc8..ae0dde5327 100644 --- a/packages/angular-table/tests/lazy-init.test.ts +++ b/packages/angular-table/tests/lazy-init.test.ts @@ -8,7 +8,7 @@ import { signal, } from '@angular/core' import { TestBed } from '@angular/core/testing' -import { lazyInit } from '../src/lazy-signal-initializer' +import { lazyInit } from '../src/lazySignalInitializer' import { flushQueue, setFixtureSignalInputs } from './test-utils' import type { WritableSignal } from '@angular/core' 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..ca62dc52d6 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', 'state'] +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 } diff --git a/packages/table-core/src/utils.ts b/packages/table-core/src/utils.ts index 6be0deeab0..5734ec3e8a 100755 --- a/packages/table-core/src/utils.ts +++ b/packages/table-core/src/utils.ts @@ -356,6 +356,9 @@ export function assignPrototypeAPIs< return fn(this, ...args) } } + Object.defineProperties(prototype[fnKey], { + originalArgsLength: { value: fn.length }, + }) } }