From aaafb07fd41de6efe5a16a622d7c27dbe88aaa46 Mon Sep 17 00:00:00 2001 From: riccardoperra Date: Sun, 9 Aug 2026 10:44:29 +0200 Subject: [PATCH 1/5] fix(angular): fix stale options during the effect first run --- .changeset/early-tips-matter.md | 5 ++++ packages/angular-table/src/injectTable.ts | 14 ++++++----- .../tests/angularReactivityFeature.test.ts | 2 +- .../angular-table/tests/injectTable.test.ts | 25 +++++++++++++++++++ 4 files changed, 39 insertions(+), 7 deletions(-) create mode 100644 .changeset/early-tips-matter.md 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..1f0d784811 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,11 +92,12 @@ export function injectTable< TFeatures extends TableFeatures, TData extends RowData, >( - options: () => TableOptions, + _options: () => TableOptions, ): AngularTable { assertInInjectionContext(injectTable) const injector = inject(Injector) const ngZone = inject(NgZone) + const options = computed(() => _options()) return ngZone.runOutsideAngular(() => lazyInit(() => { @@ -114,20 +116,20 @@ export function injectTable< table._reactivity.unmount?.() }) - let isMount = true + let previousOptions = options() effect( () => { - const newOptions = options() - if (isMount) { - isMount = false + const currentOptions = options() + if (previousOptions === currentOptions) { return } untracked(() => table.setOptions((previous) => ({ ...previous, - ...newOptions, + ...currentOptions, })), ) + previousOptions = currentOptions }, { injector, debugName: 'tableOptionsUpdate' }, ) 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) + }) }) From 8c28d3544199f03714acc43eb21d223611a9eb9d Mon Sep 17 00:00:00 2001 From: riccardoperra Date: Sun, 9 Aug 2026 18:02:52 +0200 Subject: [PATCH 2/5] fix(angular): revisit adapter to support lazy initialization --- packages/angular-table/src/injectTable.ts | 66 ++++++++++--------- .../src/lazySignalInitializer.ts | 31 ++++++--- .../angular-table/tests/lazy-init.test.ts | 27 ++++---- 3 files changed, 72 insertions(+), 52 deletions(-) diff --git a/packages/angular-table/src/injectTable.ts b/packages/angular-table/src/injectTable.ts index 1f0d784811..497a5a2428 100644 --- a/packages/angular-table/src/injectTable.ts +++ b/packages/angular-table/src/injectTable.ts @@ -97,44 +97,46 @@ export function injectTable< assertInInjectionContext(injectTable) const injector = inject(Injector) const ngZone = inject(NgZone) + const destroyRef = inject(DestroyRef) const options = computed(() => _options()) + 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 previousOptions = options() - effect( - () => { - const currentOptions = options() - if (previousOptions === currentOptions) { - return - } - untracked(() => - table.setOptions((previous) => ({ - ...previous, - ...currentOptions, - })), - ) - previousOptions = currentOptions - }, - { injector, debugName: 'tableOptionsUpdate' }, + let previousOptions: TableOptions | undefined = undefined + effect( + () => { + const currentOptions = options() + 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/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' }) From 0844a9fe24728c95aa69be9ceb5c550e06c50b66 Mon Sep 17 00:00:00 2001 From: Riccardo Perra Date: Thu, 13 Aug 2026 12:35:32 +0200 Subject: [PATCH 3/5] Apply suggestion from @riccardoperra --- packages/angular-table/src/injectTable.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/angular-table/src/injectTable.ts b/packages/angular-table/src/injectTable.ts index 497a5a2428..f52e5a96bb 100644 --- a/packages/angular-table/src/injectTable.ts +++ b/packages/angular-table/src/injectTable.ts @@ -92,13 +92,13 @@ export function injectTable< TFeatures extends TableFeatures, TData extends RowData, >( - _options: () => TableOptions, + optionsFactory: () => TableOptions, ): AngularTable { - assertInInjectionContext(injectTable) - const injector = inject(Injector) - const ngZone = inject(NgZone) +assertInInjectionContext(injectTable) +const injector = inject(Injector) +const ngZone = inject(NgZone) const destroyRef = inject(DestroyRef) - const options = computed(() => _options()) + const options = computed(() => optionsFactory()) const coreReactivityFeature = angularReactivity(injector) const lazyTable = ngZone.runOutsideAngular(() => From d9b772a536323c7d0b175ca77a1e14079478d4f7 Mon Sep 17 00:00:00 2001 From: Riccardo Perra Date: Thu, 13 Aug 2026 12:36:20 +0200 Subject: [PATCH 4/5] Apply suggestion from @riccardoperra --- packages/angular-table/src/injectTable.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/angular-table/src/injectTable.ts b/packages/angular-table/src/injectTable.ts index f52e5a96bb..7ab4871c8e 100644 --- a/packages/angular-table/src/injectTable.ts +++ b/packages/angular-table/src/injectTable.ts @@ -125,7 +125,8 @@ const ngZone = inject(NgZone) effect( () => { const currentOptions = options() - const tableInstance = lazyTable.rawValue + // rawValue will be always valued here due to internal lazyInit effect +      const tableInstance = lazyTable.rawValue if (previousOptions === currentOptions) return untracked(() => tableInstance.setOptions((previous) => ({ From 4d5fd8a42b06f27aed1bef1aafd4ad78331f57ca Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:38:25 +0000 Subject: [PATCH 5/5] ci: apply automated fixes --- packages/angular-table/src/injectTable.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/angular-table/src/injectTable.ts b/packages/angular-table/src/injectTable.ts index 7ab4871c8e..cad299f51f 100644 --- a/packages/angular-table/src/injectTable.ts +++ b/packages/angular-table/src/injectTable.ts @@ -94,9 +94,9 @@ export function injectTable< >( optionsFactory: () => TableOptions, ): AngularTable { -assertInInjectionContext(injectTable) -const injector = inject(Injector) -const ngZone = inject(NgZone) + assertInInjectionContext(injectTable) + const injector = inject(Injector) + const ngZone = inject(NgZone) const destroyRef = inject(DestroyRef) const options = computed(() => optionsFactory()) const coreReactivityFeature = angularReactivity(injector) @@ -126,7 +126,7 @@ const ngZone = inject(NgZone) () => { const currentOptions = options() // rawValue will be always valued here due to internal lazyInit effect -      const tableInstance = lazyTable.rawValue + const tableInstance = lazyTable.rawValue if (previousOptions === currentOptions) return untracked(() => tableInstance.setOptions((previous) => ({