Skip to content
Open
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/lazy-tables-initialize.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/angular-table': patch
---

Simplify lazy initialization for injected table instances
43 changes: 43 additions & 0 deletions packages/angular-table/src/injectLazyInit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import {
DestroyRef,
assertInInjectionContext,
inject,
untracked,
} from '@angular/core'

export function injectLazyInit<T extends object>(
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<T>({} 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,
}
},
})
}
44 changes: 17 additions & 27 deletions packages/angular-table/src/injectTable.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import {
DestroyRef,
Injector,
NgZone,
assertInInjectionContext,
Expand All @@ -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,
Expand Down Expand Up @@ -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<TFeatures, TData>({
...currentOptions,
features,
})
}),
injectLazyInit(
() => {
const currentOptions = options()
const features = {
coreReactivityFeature,
...currentOptions.features,
} satisfies TableFeatures
return constructTable<TFeatures, TData>({
...currentOptions,
features,
})
},
(table) => table._reactivity.unmount?.(),
),
)

destroyRef.onDestroy(() => {
if (lazyTable.initialized) {
lazyTable.value._reactivity.unmount?.()
}
})

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) => ({
lazyTable.setOptions((previous) => ({
...previous,
...currentOptions,
})),
)
previousOptions = currentOptions
},
{ injector, debugName: 'tableOptionsUpdate' },
)

return lazyTable.value
return lazyTable
}
62 changes: 0 additions & 62 deletions packages/angular-table/src/lazySignalInitializer.ts

This file was deleted.

28 changes: 27 additions & 1 deletion packages/angular-table/tests/injectTable.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isProxy } from 'node:util/types'
import { describe, expect, test, vi } from 'vitest'
import {
ChangeDetectionStrategy,
Expand All @@ -22,7 +23,7 @@ describe('injectTable', () => {

@Component({
selector: 'app-table',
template: ``,
template: `{{ table.getRowModel().rows.length }}`,
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
})
Expand Down Expand Up @@ -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' }])
Expand All @@ -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([
Expand All @@ -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<Array<{ id: string }>>()
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<Array<Data>>([{ id: '1', title: 'Title' }])
Expand All @@ -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)
Expand Down
65 changes: 45 additions & 20 deletions packages/angular-table/tests/lazy-init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,46 +8,71 @@ 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()
})
})

test('should init eagerly accessing manually', () => {
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()
Expand All @@ -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()))
})
Expand All @@ -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<string>()
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)
Expand Down