Skip to content

Commit 78f497f

Browse files
committed
refactor(angular): modernize signal-based virtualizer adapter
Adopt Angular 19 signal primitives in the adapter/proxy implementation to improve lazy initialization and reactive tracking behavior.
1 parent 72d6591 commit 78f497f

3 files changed

Lines changed: 163 additions & 158 deletions

File tree

Lines changed: 84 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
11
import {
2-
DestroyRef,
3-
afterNextRender,
2+
afterRenderEffect,
43
computed,
5-
effect,
6-
inject,
7-
signal,
4+
linkedSignal,
85
untracked,
96
} from '@angular/core'
107
import {
@@ -16,8 +13,8 @@ import {
1613
observeWindowRect,
1714
windowScroll,
1815
} from '@tanstack/virtual-core'
19-
import { proxyVirtualizer } from './proxy'
20-
import type { ElementRef, Signal } from '@angular/core'
16+
import { signalProxy } from './proxy'
17+
import type { ElementRef } from '@angular/core'
2118
import type { PartialKeys, VirtualizerOptions } from '@tanstack/virtual-core'
2219
import type { AngularVirtualizer } from './types'
2320

@@ -28,54 +25,78 @@ function createVirtualizerBase<
2825
TScrollElement extends Element | Window,
2926
TItemElement extends Element,
3027
>(
31-
options: Signal<VirtualizerOptions<TScrollElement, TItemElement>>,
32-
): AngularVirtualizer<TScrollElement, TItemElement> {
33-
let virtualizer: Virtualizer<TScrollElement, TItemElement>
34-
function lazyInit() {
35-
virtualizer ??= new Virtualizer(options())
36-
return virtualizer
37-
}
38-
39-
const virtualizerSignal = signal(virtualizer!, { equal: () => false })
28+
options: () => VirtualizerOptions<TScrollElement, TItemElement>,
29+
) {
30+
const resolvedOptions = computed<VirtualizerOptions<TScrollElement, TItemElement>>(() => {
31+
const _options = options()
32+
return {
33+
..._options,
34+
onChange: (instance, sync) => {
35+
// Update the main signal to trigger a re-render
36+
reactiveVirtualizer.set(instance)
37+
_options.onChange?.(instance, sync)
38+
},
39+
}
40+
})
4041

41-
// two-way sync options
42-
effect(
43-
() => {
44-
const _options = options()
45-
lazyInit()
46-
virtualizerSignal.set(virtualizer)
47-
virtualizer.setOptions({
48-
..._options,
49-
onChange: (instance, sync) => {
50-
// update virtualizerSignal so that dependent computeds recompute.
51-
virtualizerSignal.set(instance)
52-
_options.onChange?.(instance, sync)
53-
},
54-
})
55-
// update virtualizerSignal so that dependent computeds recompute.
56-
virtualizerSignal.set(virtualizer)
57-
},
58-
{ allowSignalWrites: true },
59-
)
42+
const lazyVirtualizer = computed(() => new Virtualizer(untracked(options)))
6043

61-
const scrollElement = computed(() => options().getScrollElement())
62-
// let the virtualizer know when the scroll element is changed
63-
effect(
64-
() => {
65-
const el = scrollElement()
66-
if (el) {
67-
untracked(virtualizerSignal)._willUpdate()
68-
}
69-
},
70-
{ allowSignalWrites: true },
71-
)
44+
const reactiveVirtualizer = linkedSignal(() => {
45+
const virtualizer = lazyVirtualizer()
46+
// If setOptions does not call onChange, it's safe to call it here
47+
virtualizer.setOptions(resolvedOptions())
48+
return virtualizer
49+
}, { equal: () => false })
7250

73-
let cleanup: () => void | undefined
74-
afterNextRender({ read: () => (virtualizer ?? lazyInit())._didMount() })
51+
afterRenderEffect((cleanup) => {
52+
cleanup(lazyVirtualizer()._didMount())
53+
})
7554

76-
inject(DestroyRef).onDestroy(() => cleanup?.())
55+
afterRenderEffect(() => {
56+
reactiveVirtualizer()._willUpdate()
57+
})
7758

78-
return proxyVirtualizer(virtualizerSignal, lazyInit)
59+
return signalProxy(
60+
reactiveVirtualizer,
61+
// Methods that pass through: call on the instance without tracking the signal read
62+
[
63+
'_didMount',
64+
'_willUpdate',
65+
'calculateRange',
66+
'getVirtualIndexes',
67+
'measure',
68+
'measureElement',
69+
'resizeItem',
70+
'scrollBy',
71+
'scrollToIndex',
72+
'scrollToOffset',
73+
'setOptions',
74+
],
75+
// Attributes that will be transformed to signals
76+
[
77+
'isScrolling',
78+
'measurementsCache',
79+
'options',
80+
'range',
81+
'scrollDirection',
82+
'scrollElement',
83+
'scrollOffset',
84+
'scrollRect',
85+
],
86+
// Methods that will be tracked to the virtualizer signal
87+
[
88+
'getOffsetForAlignment',
89+
'getOffsetForIndex',
90+
'getVirtualItemForOffset',
91+
'indexFromElement',
92+
],
93+
// Zero-arg methods exposed as computed signals
94+
[
95+
'getTotalSize',
96+
'getVirtualItems'
97+
],
98+
// The rest is passed as is, and can be accessed or called before initialization
99+
) as unknown as AngularVirtualizer<TScrollElement, TItemElement>
79100
}
80101

81102
export function injectVirtualizer<
@@ -89,23 +110,23 @@ export function injectVirtualizer<
89110
scrollElement: ElementRef<TScrollElement> | TScrollElement | undefined
90111
},
91112
): AngularVirtualizer<TScrollElement, TItemElement> {
92-
const resolvedOptions = computed(() => {
113+
return createVirtualizerBase<TScrollElement, TItemElement>(() => {
114+
const _options = options()
93115
return {
94116
observeElementRect: observeElementRect,
95117
observeElementOffset: observeElementOffset,
96118
scrollToFn: elementScroll,
97119
getScrollElement: () => {
98-
const elementOrRef = options().scrollElement
120+
const elementOrRef = _options.scrollElement
99121
return (
100122
(isElementRef(elementOrRef)
101123
? elementOrRef.nativeElement
102124
: elementOrRef) ?? null
103125
)
104126
},
105-
...options(),
127+
..._options,
106128
}
107129
})
108-
return createVirtualizerBase<TScrollElement, TItemElement>(resolvedOptions)
109130
}
110131

111132
function isElementRef<T extends Element>(
@@ -123,16 +144,13 @@ export function injectWindowVirtualizer<TItemElement extends Element>(
123144
| 'scrollToFn'
124145
>,
125146
): AngularVirtualizer<Window, TItemElement> {
126-
const resolvedOptions = computed(() => {
127-
return {
128-
getScrollElement: () => (typeof document !== 'undefined' ? window : null),
129-
observeElementRect: observeWindowRect,
130-
observeElementOffset: observeWindowOffset,
131-
scrollToFn: windowScroll,
132-
initialOffset: () =>
133-
typeof document !== 'undefined' ? window.scrollY : 0,
134-
...options(),
135-
}
136-
})
137-
return createVirtualizerBase<Window, TItemElement>(resolvedOptions)
147+
return createVirtualizerBase<Window, TItemElement>(() => ({
148+
getScrollElement: () => (typeof document !== 'undefined' ? window : null),
149+
observeElementRect: observeWindowRect,
150+
observeElementOffset: observeWindowOffset,
151+
scrollToFn: windowScroll,
152+
initialOffset: () =>
153+
typeof document !== 'undefined' ? window.scrollY : 0,
154+
...options(),
155+
}))
138156
}
Lines changed: 74 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,117 +1,99 @@
11
import { computed, untracked } from '@angular/core'
2-
import type { Signal, WritableSignal } from '@angular/core'
3-
import type { Virtualizer } from '@tanstack/virtual-core'
4-
import type { AngularVirtualizer } from './types'
2+
import type { Signal } from '@angular/core'
53

6-
export function proxyVirtualizer<
7-
V extends Virtualizer<any, any>,
8-
S extends Element | Window = V extends Virtualizer<infer U, any> ? U : never,
9-
I extends Element = V extends Virtualizer<any, infer U> ? U : never,
4+
export type SignalProxy<
5+
TInput extends Record<string | symbol, any>,
6+
TMethodsToPassThrough extends keyof TInput,
7+
TAttributesToTransformToSignals extends keyof TInput,
8+
TMethodsToTrack extends keyof TInput,
9+
TMethodsToTransformToSignals extends keyof TInput,
10+
> = {
11+
[K in TMethodsToPassThrough]: TInput[K]
12+
} & {
13+
[K in TAttributesToTransformToSignals]: Signal<TInput[K]>
14+
} & {
15+
[K in TMethodsToTrack]: TInput[K]
16+
} & {
17+
[K in TMethodsToTransformToSignals]: Signal<ReturnType<TInput[K]>>
18+
}
19+
20+
export function signalProxy<
21+
TInput extends Record<string | symbol, any>,
22+
TMethodsToPassThrough extends keyof TInput,
23+
TAttributesToTransformToSignals extends keyof TInput,
24+
TMethodsToTrack extends keyof TInput,
25+
TMethodsToTransformToSignals extends keyof TInput,
1026
>(
11-
virtualizerSignal: WritableSignal<V>,
12-
lazyInit: () => V,
13-
): AngularVirtualizer<S, I> {
14-
return new Proxy(virtualizerSignal, {
27+
inputSignal: Signal<TInput>,
28+
methodsToPassThrough: Array<TMethodsToPassThrough>,
29+
attributesToTransformToSignals: Array<TAttributesToTransformToSignals>,
30+
methodsToTrack: Array<TMethodsToTrack>,
31+
methodsToTransformToSignals: Array<TMethodsToTransformToSignals>,
32+
): SignalProxy<
33+
TInput,
34+
TMethodsToPassThrough,
35+
TAttributesToTransformToSignals,
36+
TMethodsToTrack,
37+
TMethodsToTransformToSignals
38+
> {
39+
// Type needed to proxy with the apply handler
40+
const callableTarget = (() => inputSignal()) as (() => TInput) &
41+
Record<PropertyKey, unknown>
42+
43+
return new Proxy(callableTarget, {
1544
apply() {
16-
return virtualizerSignal()
45+
return inputSignal()
1746
},
1847
get(target, property) {
19-
const untypedTarget = target as any
20-
if (untypedTarget[property]) {
21-
return untypedTarget[property]
48+
const fieldValue = target[property as keyof typeof callableTarget]
49+
if (fieldValue !== undefined) return fieldValue
50+
51+
// Methods that pass through: call on the instance without tracking the signal read
52+
if (methodsToPassThrough.includes(property as TMethodsToPassThrough)) {
53+
return (target[property] = (...args: Parameters<TInput[typeof property]>) =>
54+
untracked(inputSignal)[property as keyof TInput](...args))
2255
}
23-
let virtualizer = untracked(virtualizerSignal)
24-
if (virtualizer == null) {
25-
virtualizer = lazyInit()
26-
untracked(() => virtualizerSignal.set(virtualizer))
56+
57+
// Zero-arg methods exposed as computed signals (matches main list A for getTotalSize / getVirtualItems)
58+
if (methodsToTransformToSignals.includes(property as TMethodsToTransformToSignals)) {
59+
return (target[property] = computed(
60+
() => (inputSignal()[property as keyof TInput] as () => unknown)()
61+
))
2762
}
2863

29-
// Create computed signals for each property that represents a reactive value
30-
if (
31-
typeof property === 'string' &&
32-
[
33-
'getTotalSize',
34-
'getVirtualItems',
35-
'isScrolling',
36-
'options',
37-
'range',
38-
'scrollDirection',
39-
'scrollElement',
40-
'scrollOffset',
41-
'scrollRect',
42-
'measureElementCache',
43-
'measurementsCache',
44-
].includes(property)
45-
) {
46-
const isFunction =
47-
typeof virtualizer[property as keyof V] === 'function'
48-
Object.defineProperty(untypedTarget, property, {
49-
value: isFunction
50-
? computed(() => (target()[property as keyof V] as Function)())
51-
: computed(() => target()[property as keyof V]),
52-
configurable: true,
53-
enumerable: true,
54-
})
64+
// Methods that need to be tracked, track instance changes and call the method
65+
if (methodsToTrack.includes(property as TMethodsToTrack)) {
66+
return (target[property] = (...args: Parameters<TInput[typeof property]>) =>
67+
inputSignal()[property as keyof TInput](...args))
5568
}
5669

57-
// Create plain signals for functions that accept arguments and return reactive values
58-
if (
59-
typeof property === 'string' &&
60-
[
61-
'getOffsetForAlignment',
62-
'getOffsetForIndex',
63-
'getVirtualItemForOffset',
64-
'indexFromElement',
65-
].includes(property)
66-
) {
67-
const fn = virtualizer[property as keyof V] as Function
68-
Object.defineProperty(untypedTarget, property, {
69-
value: toComputed(virtualizerSignal, fn),
70-
configurable: true,
71-
enumerable: true,
72-
})
70+
// Other values that are tracked as signals
71+
if (attributesToTransformToSignals.includes(property as TAttributesToTransformToSignals)) {
72+
return (target[property] = computed(() => inputSignal()[property as keyof TInput]))
7373
}
7474

75-
return untypedTarget[property] || virtualizer[property as keyof V]
75+
// All other fiels. Any field that is not handled above will fail if the signal includes
76+
// a input or model from a component and this is accessed before initialization.
77+
return untracked(inputSignal)[property as keyof TInput]
7678
},
7779
has(_, property: string) {
78-
return !!untracked(virtualizerSignal)[property as keyof V]
80+
return property in untracked(inputSignal)
7981
},
8082
ownKeys() {
81-
return Reflect.ownKeys(untracked(virtualizerSignal))
83+
return Reflect.ownKeys(untracked(inputSignal))
8284
},
8385
getOwnPropertyDescriptor() {
8486
return {
8587
enumerable: true,
8688
configurable: true,
89+
writable: true,
8790
}
8891
},
89-
}) as unknown as AngularVirtualizer<S, I>
90-
}
91-
92-
function toComputed<V extends Virtualizer<any, any>>(
93-
signal: Signal<V>,
94-
fn: Function,
95-
) {
96-
const computedCache: Record<string, Signal<unknown>> = {}
97-
98-
return (...args: Array<any>) => {
99-
// Cache computeds by their arguments to avoid re-creating the computed on each call
100-
const serializedArgs = serializeArgs(...args)
101-
if (computedCache.hasOwnProperty(serializedArgs)) {
102-
return computedCache[serializedArgs]?.()
103-
}
104-
const computedSignal = computed(() => {
105-
void signal()
106-
return fn(...args)
107-
})
108-
109-
computedCache[serializedArgs] = computedSignal
110-
111-
return computedSignal()
112-
}
113-
}
114-
115-
function serializeArgs(...args: Array<any>) {
116-
return JSON.stringify(args)
92+
}) as SignalProxy<
93+
TInput,
94+
TMethodsToPassThrough,
95+
TAttributesToTransformToSignals,
96+
TMethodsToTrack,
97+
TMethodsToTransformToSignals
98+
>
11799
}

0 commit comments

Comments
 (0)