From cc12073dbcd310dca43e08707602868047b9869d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjam=C3=ADn=20Vicente?= Date: Sun, 16 Aug 2026 13:18:31 -0400 Subject: [PATCH] refactor(angular): simplify lazy table initialization --- .changeset/lazy-tables-initialize.md | 5 ++ packages/angular-table/src/injectLazyInit.ts | 43 ++++++++++++ packages/angular-table/src/injectTable.ts | 44 +++++-------- .../src/lazySignalInitializer.ts | 62 ------------------ .../angular-table/tests/injectTable.test.ts | 28 +++++++- .../angular-table/tests/lazy-init.test.ts | 65 +++++++++++++------ 6 files changed, 137 insertions(+), 110 deletions(-) create mode 100644 .changeset/lazy-tables-initialize.md create mode 100644 packages/angular-table/src/injectLazyInit.ts delete mode 100644 packages/angular-table/src/lazySignalInitializer.ts diff --git a/.changeset/lazy-tables-initialize.md b/.changeset/lazy-tables-initialize.md new file mode 100644 index 0000000000..fcce36e110 --- /dev/null +++ b/.changeset/lazy-tables-initialize.md @@ -0,0 +1,5 @@ +--- +'@tanstack/angular-table': patch +--- + +Simplify lazy initialization for injected table instances diff --git a/packages/angular-table/src/injectLazyInit.ts b/packages/angular-table/src/injectLazyInit.ts new file mode 100644 index 0000000000..c8ab7eb4aa --- /dev/null +++ b/packages/angular-table/src/injectLazyInit.ts @@ -0,0 +1,43 @@ +import { + DestroyRef, + assertInInjectionContext, + inject, + untracked, +} from '@angular/core' + +export function injectLazyInit( + initializer: () => T, + cleanup: (object: T) => void, +): T { + assertInInjectionContext(injectLazyInit) + const destroyRef = inject(DestroyRef) + let object: T | null = null + + const getObject = () => { + if (object === null) { + const initializedObject = untracked(initializer) + object = initializedObject + destroyRef.onDestroy(() => cleanup(initializedObject)) + } + + return object + } + + return new Proxy({} as T, { + get(_, prop, receiver) { + return Reflect.get(getObject(), prop, receiver) + }, + has(_, prop) { + return Reflect.has(getObject(), prop) + }, + ownKeys() { + return Reflect.ownKeys(getObject()) + }, + getOwnPropertyDescriptor() { + return { + enumerable: true, + configurable: true, + } + }, + }) +} diff --git a/packages/angular-table/src/injectTable.ts b/packages/angular-table/src/injectTable.ts index cad299f51f..e4ab204b7e 100644 --- a/packages/angular-table/src/injectTable.ts +++ b/packages/angular-table/src/injectTable.ts @@ -1,5 +1,4 @@ import { - DestroyRef, Injector, NgZone, assertInInjectionContext, @@ -9,7 +8,7 @@ import { untracked, } from '@angular/core' import { constructTable } from '@tanstack/table-core' -import { lazyInit } from './lazySignalInitializer' +import { injectLazyInit } from './injectLazyInit' import { angularReactivity } from './reactivity' import type { RowData, @@ -97,47 +96,38 @@ export function injectTable< assertInInjectionContext(injectTable) const injector = inject(Injector) const ngZone = inject(NgZone) - const destroyRef = inject(DestroyRef) const options = computed(() => optionsFactory()) const coreReactivityFeature = angularReactivity(injector) const lazyTable = ngZone.runOutsideAngular(() => - lazyInit(() => { - const currentOptions = options() - const features = { - coreReactivityFeature, - ...currentOptions.features, - } satisfies TableFeatures - return constructTable({ - ...currentOptions, - features, - }) - }), + injectLazyInit( + () => { + const currentOptions = options() + const features = { + coreReactivityFeature, + ...currentOptions.features, + } satisfies TableFeatures + return constructTable({ + ...currentOptions, + features, + }) + }, + (table) => table._reactivity.unmount?.(), + ), ) - destroyRef.onDestroy(() => { - if (lazyTable.initialized) { - lazyTable.value._reactivity.unmount?.() - } - }) - - let previousOptions: TableOptions | undefined = undefined effect( () => { const currentOptions = options() - // rawValue will be always valued here due to internal lazyInit effect - const tableInstance = lazyTable.rawValue - if (previousOptions === currentOptions) return untracked(() => - tableInstance.setOptions((previous) => ({ + lazyTable.setOptions((previous) => ({ ...previous, ...currentOptions, })), ) - previousOptions = currentOptions }, { injector, debugName: 'tableOptionsUpdate' }, ) - return lazyTable.value + return lazyTable } diff --git a/packages/angular-table/src/lazySignalInitializer.ts b/packages/angular-table/src/lazySignalInitializer.ts deleted file mode 100644 index 23eeb31eea..0000000000 --- a/packages/angular-table/src/lazySignalInitializer.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { assertInInjectionContext, effect, untracked } from '@angular/core' - -export function lazyInit( - initializer: () => T, -): { - readonly rawValue: T - readonly value: T - readonly initialized: boolean -} { - assertInInjectionContext(lazyInit) - let object: T | null = null - - const initializeObject = () => { - if (!object) { - object = untracked(() => initializer()) - } - } - - effect(() => initializeObject(), { - debugName: 'tableLazyInitEffect', - }) - - const table = () => {} - - const proxy = new Proxy(table as T, { - apply(target: T, thisArg: any, argArray: Array): any { - initializeObject() - if (typeof object === 'function') { - return Reflect.apply(object, thisArg, argArray) - } - return Reflect.apply(target as any, thisArg, argArray) - }, - get(_, prop, receiver) { - initializeObject() - return Reflect.get(object as T, prop, receiver) - }, - has(_, prop) { - initializeObject() - return Reflect.has(object as T, prop) - }, - ownKeys() { - initializeObject() - return Reflect.ownKeys(object as T) - }, - getOwnPropertyDescriptor() { - return { - enumerable: true, - configurable: true, - } - }, - }) - - return { - value: proxy, - get rawValue() { - return object as T - }, - get initialized() { - return !!object - }, - } -} diff --git a/packages/angular-table/tests/injectTable.test.ts b/packages/angular-table/tests/injectTable.test.ts index e8804b5b5b..69d1d54341 100644 --- a/packages/angular-table/tests/injectTable.test.ts +++ b/packages/angular-table/tests/injectTable.test.ts @@ -1,3 +1,4 @@ +import { isProxy } from 'node:util/types' import { describe, expect, test, vi } from 'vitest' import { ChangeDetectionStrategy, @@ -22,7 +23,7 @@ describe('injectTable', () => { @Component({ selector: 'app-table', - template: ``, + template: `{{ table.getRowModel().rows.length }}`, standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, }) @@ -55,6 +56,7 @@ describe('injectTable', () => { By.directive(TableComponent), ).componentInstance as TableComponent + expect(fixture.nativeElement.textContent.trim()).toBe('1') expect( tableComponent.table.getRowModel().rows.map((row) => row.original), ).toEqual([{ id: '1', title: 'First' }]) @@ -68,6 +70,7 @@ describe('injectTable', () => { TestBed.tick() await fixture.whenRenderingDone() + expect(fixture.nativeElement.textContent.trim()).toBe('2') expect( tableComponent.table.getRowModel().rows.map((row) => row.original), ).toEqual([ @@ -76,6 +79,22 @@ describe('injectTable', () => { ]) }) + test('should not initialize when destroyed before the first effect', () => { + @Component({ standalone: true, template: `` }) + class TableComponent { + readonly data = input.required>() + readonly table = injectTable(() => ({ + data: this.data(), + features: stockFeatures, + columns: [], + })) + } + + const fixture = TestBed.createComponent(TableComponent) + + expect(() => fixture.destroy()).not.toThrow() + }) + describe('Proxy table', () => { type Data = { id: string; title: string } const data = signal>([{ id: '1', title: 'Title' }]) @@ -92,6 +111,13 @@ describe('injectTable', () => { })), ) + test('exposes a table instance through the proxy', () => { + expect(isProxy(table)).toBe(true) + expect(table).toBeDefined() + expect(typeof table).toBe('object') + expect(typeof table.getRowModel).toBe('function') + }) + test('supports "in" operator', () => { expect('atoms' in table).toBe(true) expect('options' in table).toBe(true) diff --git a/packages/angular-table/tests/lazy-init.test.ts b/packages/angular-table/tests/lazy-init.test.ts index ebb7e9867c..ed8a68b39d 100644 --- a/packages/angular-table/tests/lazy-init.test.ts +++ b/packages/angular-table/tests/lazy-init.test.ts @@ -8,31 +8,56 @@ import { signal, } from '@angular/core' import { TestBed } from '@angular/core/testing' -import { lazyInit } from '../src/lazySignalInitializer' +import { injectLazyInit } from '../src/injectLazyInit' import { flushQueue, setFixtureSignalInputs } from './test-utils' import type { WritableSignal } from '@angular/core' -describe('lazyInit', () => { - test('should init lazily in next tick when not accessing manually', () => { +describe('injectLazyInit', () => { + test('should register cleanup only after initialization', () => { + const initializedObject = { data: signal(true) } + const initializer = vi.fn(() => initializedObject) + const cleanup = vi.fn<(object: typeof initializedObject) => void>() + + @Component({ standalone: true, template: `` }) + class Test { + readonly lazySignal = injectLazyInit(initializer, cleanup) + } + + const uninitializedFixture = TestBed.createComponent(Test) + uninitializedFixture.destroy() + + expect(initializer).not.toHaveBeenCalled() + expect(cleanup).not.toHaveBeenCalled() + + const initializedFixture = TestBed.createComponent(Test) + initializedFixture.componentInstance.lazySignal.data() + initializedFixture.destroy() + + expect(initializer).toHaveBeenCalledOnce() + expect(cleanup).toHaveBeenCalledOnce() + expect(cleanup).toHaveBeenCalledWith(initializedObject) + }) + + test('should not initialize until accessed', () => { const mockFn = vi.fn() TestBed.runInInjectionContext(() => { - const proxy = lazyInit(() => { + const proxy = injectLazyInit(() => { mockFn() return { data: signal(true), } - }) + }, vi.fn()) expect(mockFn).not.toHaveBeenCalled() - expect(proxy.initialized).toEqual(false) - expect(proxy.rawValue).toBeNullable() TestBed.tick() - expect(proxy.initialized).toEqual(true) - expect(proxy.rawValue).not.toBeNullable() - expect(mockFn).toHaveBeenCalled() + expect(mockFn).not.toHaveBeenCalled() + + proxy.data() + + expect(mockFn).toHaveBeenCalledOnce() }) }) @@ -40,14 +65,14 @@ describe('lazyInit', () => { const mockFn = vi.fn() TestBed.runInInjectionContext(() => { - const lazySignal = lazyInit(() => { + const lazySignal = injectLazyInit(() => { mockFn() return { data: signal(true), } - }) + }, vi.fn()) - lazySignal.value.data() + lazySignal.data() }) expect(mockFn).toHaveBeenCalled() @@ -61,13 +86,13 @@ describe('lazyInit', () => { const outerSignal = signal(0) TestBed.runInInjectionContext(() => { - value = lazyInit(() => { + value = injectLazyInit(() => { initCallFn() void outerSignal() return { data: signal(0) } - }).value + }, vi.fn()) effect(() => registerDataValue(value.data())) }) @@ -94,19 +119,19 @@ describe('lazyInit', () => { test('should support required signal input', async () => { @Component({ standalone: true, - template: `{{ call }} - {{ lazySignal.data() }}`, + template: `{{ call() }} - {{ lazySignal.data() }}`, changeDetection: ChangeDetectionStrategy.OnPush, }) class Test { readonly title = input.required() - call = 0 + readonly call = signal(0) - lazySignal = lazyInit(() => { - this.call++ + lazySignal = injectLazyInit(() => { + this.call.update((value) => value + 1) return { data: computed(() => this.title()), } - }).value + }, vi.fn()) } const fixture = TestBed.createComponent(Test)