Skip to content

Commit 59b5a29

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 7933dbb commit 59b5a29

3 files changed

Lines changed: 162 additions & 161 deletions

File tree

Lines changed: 83 additions & 69 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,58 +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({
75-
read: () => {
76-
cleanup = (virtualizer ?? lazyInit())._didMount()
77-
},
51+
afterRenderEffect((cleanup) => {
52+
cleanup(lazyVirtualizer()._didMount())
7853
})
7954

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

82-
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>
83100
}
84101

85102
export function injectVirtualizer<
@@ -93,23 +110,23 @@ export function injectVirtualizer<
93110
scrollElement: ElementRef<TScrollElement> | TScrollElement | undefined
94111
},
95112
): AngularVirtualizer<TScrollElement, TItemElement> {
96-
const resolvedOptions = computed(() => {
113+
return createVirtualizerBase<TScrollElement, TItemElement>(() => {
114+
const _options = options()
97115
return {
98116
observeElementRect: observeElementRect,
99117
observeElementOffset: observeElementOffset,
100118
scrollToFn: elementScroll,
101119
getScrollElement: () => {
102-
const elementOrRef = options().scrollElement
120+
const elementOrRef = _options.scrollElement
103121
return (
104122
(isElementRef(elementOrRef)
105123
? elementOrRef.nativeElement
106124
: elementOrRef) ?? null
107125
)
108126
},
109-
...options(),
127+
..._options,
110128
}
111129
})
112-
return createVirtualizerBase<TScrollElement, TItemElement>(resolvedOptions)
113130
}
114131

115132
function isElementRef<T extends Element>(
@@ -127,16 +144,13 @@ export function injectWindowVirtualizer<TItemElement extends Element>(
127144
| 'scrollToFn'
128145
>,
129146
): AngularVirtualizer<Window, TItemElement> {
130-
const resolvedOptions = computed(() => {
131-
return {
132-
getScrollElement: () => (typeof document !== 'undefined' ? window : null),
133-
observeElementRect: observeWindowRect,
134-
observeElementOffset: observeWindowOffset,
135-
scrollToFn: windowScroll,
136-
initialOffset: () =>
137-
typeof document !== 'undefined' ? window.scrollY : 0,
138-
...options(),
139-
}
140-
})
141-
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+
}))
142156
}
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+
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)