Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/early-tips-matter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/angular-table': patch
---

Ensure options updates are not missed during first mount
71 changes: 38 additions & 33 deletions packages/angular-table/src/injectTable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
Injector,
NgZone,
assertInInjectionContext,
computed,
effect,
inject,
untracked,
Expand Down Expand Up @@ -91,48 +92,52 @@ export function injectTable<
TFeatures extends TableFeatures,
TData extends RowData,
>(
options: () => TableOptions<TFeatures, TData>,
optionsFactory: () => TableOptions<TFeatures, TData>,
): AngularTable<TFeatures, TData> {
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<TFeatures, TData>({
...options(),
features: {
coreReactivityFeature: angularReactivity(injector),
...options().features,
},
const currentOptions = options()
const features = {
coreReactivityFeature,
...currentOptions.features,
} satisfies TableFeatures
return constructTable<TFeatures, TData>({
...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<TFeatures, TData> | 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
}
31 changes: 23 additions & 8 deletions packages/angular-table/src/lazySignalInitializer.ts
Original file line number Diff line number Diff line change
@@ -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<T extends object>(initializer: () => T): T {
export function lazyInit<T extends object>(
initializer: () => T,
): {
readonly rawValue: T
readonly value: T
readonly initialized: boolean
} {
assertInInjectionContext(lazyInit)
let object: T | null = null

const initializeObject = () => {
Expand All @@ -13,11 +16,13 @@ export function lazyInit<T extends object>(initializer: () => T): T {
}
}

queueMicrotask(() => initializeObject())
effect(() => initializeObject(), {
debugName: 'tableLazyInitEffect',
})

const table = () => {}

return new Proxy<T>(table as T, {
const proxy = new Proxy<T>(table as T, {
apply(target: T, thisArg: any, argArray: Array<any>): any {
initializeObject()
if (typeof object === 'function') {
Expand All @@ -44,4 +49,14 @@ export function lazyInit<T extends object>(initializer: () => T): T {
}
},
})

return {
value: proxy,
get rawValue() {
return object as T
},
get initialized() {
return !!object
},
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ describe('angularReactivityFeature', () => {
return TestBed.runInInjectionContext(() =>
injectTable(() => ({
data: _data(),
features: { ...stockFeatures },
features: stockFeatures,
columns: columns,
getRowId: (row) => row.id,
})),
Expand Down
25 changes: 25 additions & 0 deletions packages/angular-table/tests/injectTable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Data> = []
const updatedData: Array<Data> = [{ 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)
})
})
27 changes: 15 additions & 12 deletions packages/angular-table/tests/lazy-init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -43,7 +47,7 @@ describe('lazyInit', () => {
}
})

lazySignal.data()
lazySignal.value.data()
})

expect(mockFn).toHaveBeenCalled()
Expand All @@ -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()

Expand Down Expand Up @@ -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' })
Expand Down
Loading