diff --git a/.changeset/early-tips-matter.md b/.changeset/early-tips-matter.md new file mode 100644 index 0000000000..616a7482d9 --- /dev/null +++ b/.changeset/early-tips-matter.md @@ -0,0 +1,5 @@ +--- +'@tanstack/angular-table': patch +--- + +Ensure options updates are not missed during first mount diff --git a/packages/angular-table/src/injectTable.ts b/packages/angular-table/src/injectTable.ts index 3486e93f7c..cad299f51f 100644 --- a/packages/angular-table/src/injectTable.ts +++ b/packages/angular-table/src/injectTable.ts @@ -3,6 +3,7 @@ import { Injector, NgZone, assertInInjectionContext, + computed, effect, inject, untracked, @@ -91,48 +92,52 @@ export function injectTable< TFeatures extends TableFeatures, TData extends RowData, >( - options: () => TableOptions, + optionsFactory: () => TableOptions, ): AngularTable { assertInInjectionContext(injectTable) const injector = inject(Injector) const ngZone = inject(NgZone) + const destroyRef = inject(DestroyRef) + const options = computed(() => optionsFactory()) + const coreReactivityFeature = angularReactivity(injector) - return ngZone.runOutsideAngular(() => + const lazyTable = ngZone.runOutsideAngular(() => lazyInit(() => { - // Explicit type arguments skip generic inference from the spread object - // (a type-check hot spot); the spread only adds the angular reactivity - // binding to `features`. - const table = constructTable({ - ...options(), - features: { - coreReactivityFeature: angularReactivity(injector), - ...options().features, - }, + const currentOptions = options() + const features = { + coreReactivityFeature, + ...currentOptions.features, + } satisfies TableFeatures + return constructTable({ + ...currentOptions, + features, }) + }), + ) - injector.get(DestroyRef).onDestroy(() => { - table._reactivity.unmount?.() - }) + destroyRef.onDestroy(() => { + if (lazyTable.initialized) { + lazyTable.value._reactivity.unmount?.() + } + }) - let isMount = true - effect( - () => { - const newOptions = options() - if (isMount) { - isMount = false - return - } - untracked(() => - table.setOptions((previous) => ({ - ...previous, - ...newOptions, - })), - ) - }, - { injector, debugName: 'tableOptionsUpdate' }, + 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) => ({ + ...previous, + ...currentOptions, + })), ) - - return table - }), + previousOptions = currentOptions + }, + { injector, debugName: 'tableOptionsUpdate' }, ) + + return lazyTable.value } diff --git a/packages/angular-table/src/lazySignalInitializer.ts b/packages/angular-table/src/lazySignalInitializer.ts index 92f8dcc901..23eeb31eea 100644 --- a/packages/angular-table/src/lazySignalInitializer.ts +++ b/packages/angular-table/src/lazySignalInitializer.ts @@ -1,10 +1,13 @@ -import { untracked } from '@angular/core' +import { assertInInjectionContext, effect, 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): T { +export function lazyInit( + initializer: () => T, +): { + readonly rawValue: T + readonly value: T + readonly initialized: boolean +} { + assertInInjectionContext(lazyInit) let object: T | null = null const initializeObject = () => { @@ -13,11 +16,13 @@ export function lazyInit(initializer: () => T): T { } } - queueMicrotask(() => initializeObject()) + effect(() => initializeObject(), { + debugName: 'tableLazyInitEffect', + }) const table = () => {} - return new Proxy(table as T, { + const proxy = new Proxy(table as T, { apply(target: T, thisArg: any, argArray: Array): any { initializeObject() if (typeof object === 'function') { @@ -44,4 +49,14 @@ export function lazyInit(initializer: () => T): T { } }, }) + + return { + value: proxy, + get rawValue() { + return object as T + }, + get initialized() { + return !!object + }, + } } diff --git a/packages/angular-table/tests/angularReactivityFeature.test.ts b/packages/angular-table/tests/angularReactivityFeature.test.ts index 52074477f8..d8ab8f7ed5 100644 --- a/packages/angular-table/tests/angularReactivityFeature.test.ts +++ b/packages/angular-table/tests/angularReactivityFeature.test.ts @@ -28,7 +28,7 @@ describe('angularReactivityFeature', () => { return TestBed.runInInjectionContext(() => injectTable(() => ({ data: _data(), - features: { ...stockFeatures }, + features: stockFeatures, columns: columns, getRowId: (row) => row.id, })), diff --git a/packages/angular-table/tests/injectTable.test.ts b/packages/angular-table/tests/injectTable.test.ts index aee244543d..e8804b5b5b 100644 --- a/packages/angular-table/tests/injectTable.test.ts +++ b/packages/angular-table/tests/injectTable.test.ts @@ -147,4 +147,29 @@ describe('injectTable', () => { }) }) }) + + // Fixes https://github.com/TanStack/table/issues/6530 + test('does not drop an options update before the effect first runs', () => { + type Data = { id: string } + + const initialData: Array = [] + const updatedData: Array = [{ id: '1' }] + const data = signal(initialData) + + const table = TestBed.runInInjectionContext(() => + injectTable(() => ({ + data: data(), + columns: [], + features: stockFeatures, + getRowId: (row) => row.id, + })), + ) + + expect(table.options.data).toBe(initialData) + + data.set(updatedData) + TestBed.tick() + + expect(table.options.data).toBe(updatedData) + }) }) diff --git a/packages/angular-table/tests/lazy-init.test.ts b/packages/angular-table/tests/lazy-init.test.ts index ae0dde5327..ebb7e9867c 100644 --- a/packages/angular-table/tests/lazy-init.test.ts +++ b/packages/angular-table/tests/lazy-init.test.ts @@ -13,23 +13,27 @@ 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', async () => { + test('should init lazily in next tick when not accessing manually', () => { const mockFn = vi.fn() TestBed.runInInjectionContext(() => { - lazyInit(() => { + const proxy = lazyInit(() => { mockFn() return { data: signal(true), } }) - }) - expect(mockFn).not.toHaveBeenCalled() + expect(mockFn).not.toHaveBeenCalled() + expect(proxy.initialized).toEqual(false) + expect(proxy.rawValue).toBeNullable() - await new Promise(setImmediate) + TestBed.tick() - expect(mockFn).toHaveBeenCalled() + expect(proxy.initialized).toEqual(true) + expect(proxy.rawValue).not.toBeNullable() + expect(mockFn).toHaveBeenCalled() + }) }) test('should init eagerly accessing manually', () => { @@ -43,7 +47,7 @@ describe('lazyInit', () => { } }) - lazySignal.data() + lazySignal.value.data() }) expect(mockFn).toHaveBeenCalled() @@ -63,14 +67,14 @@ describe('lazyInit', () => { void outerSignal() return { data: signal(0) } - }) + }).value effect(() => registerDataValue(value.data())) }) value.data() - TestBed.flushEffects() + TestBed.tick() expect(outerSignal).toBeDefined() @@ -102,13 +106,12 @@ describe('lazyInit', () => { return { data: computed(() => this.title()), } - }) + }).value } const fixture = TestBed.createComponent(Test) - setFixtureSignalInputs(fixture, { title: 'newValue' }) - expect(fixture.debugElement.nativeElement.textContent).toBe('0 - newValue') + expect(fixture.debugElement.nativeElement.textContent).toBe('1 - newValue') await flushQueue() setFixtureSignalInputs(fixture, { title: 'updatedValue' })