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 numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@tanstack/angular-table': patch
---

Simplify lazy initialization for injected table instances
45 changes: 45 additions & 0 deletions packages/angular-table/src/injectLazyInit.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
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
if (!destroyRef.destroyed) {
destroyRef.onDestroy(() => cleanup(initializedObject))
}
}

return object
}

return new Proxy<T>({} as T, {
Comment thread
riccardoperra marked this conversation as resolved.
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,
}
},
Comment thread
riccardoperra marked this conversation as resolved.
})
}
44 changes: 17 additions & 27 deletions packages/angular-table/src/injectTable.ts
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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
Loading