Rocket Virtual Scroll Pro
Rocket is currently in alpha – available in the Datastar Pro repo.
Explanation #
The Virtual Scroll component efficiently renders large lists by only loading visible content plus buffers. It uses a three-block recycling system to handle smooth scrolling in both directions.
How It Works #
The virtual scroller maintains three blocks (A, B, C) of content. As you scroll down, the top block is recycled to the bottom. As you scroll up, the bottom block is recycled to the top. This creates an infinite scrolling effect with minimal DOM elements.
Performance Tips #
For best performance, ensure your items have consistent or measurable heights. The component automatically calculates average item height, but consistent heights provide the smoothest experience.
The key features of this Rocket Virtual Scroll component include:
- Efficient rendering: Only loads 3 buffers of content at a time.
- Smooth scrolling: Pre-loads content above and below viewport.
- Block recycling: Reuses DOM elements as you scroll.
- Fast jumping: Can jump to any position instantly.
- Dynamic heights: Automatically measures and adapts to item heights.
Usage Example #
Rocket Component #
1<template data-rocket:rocket-virtual-scroll
2 data-prop:url="str trim"
3 data-prop:initial-index="int (min 0)"
4 data-prop:buffer-size="int (min 1) (= 50)"
5>
6 <script>
7 const blocks = { A: {}, B: {}, C: {} }
8
9 $$blockAStartIndex = 0
10 $$blockBStartIndex = 0
11 $$blockCStartIndex = 0
12 $$isLoading = false
13 $$blockAY = 0
14 $$blockBY = 0
15 $$blockCY = 0
16 $$avgItemHeight = 50
17 $$totalItems = 0
18 $$scrollHeight = 0
19
20 $$validUrl = $$url
21 $$validInitialIndex = $$initialIndex
22 $$validBufferSize = $$bufferSize
23
24 $$blockAStartIndex = $$validInitialIndex - $$validBufferSize
25 $$blockBStartIndex = $$validInitialIndex
26 $$blockCStartIndex = $$validInitialIndex + $$validBufferSize
27
28 $$blockPositions = ['A', 'B', 'C']
29 $$measuredItems = 0
30 $$totalMeasuredHeight = 0
31
32 effect(() => $$scrollHeight = $$totalItems * $$avgItemHeight)
33
34 el.style.display = 'block'
35
36 let jumpTimeout = null
37 let hasInitializedScroll = false
38
39 const blockState = {
40 get: (name, prop) => {
41 if (prop === 'startIndex') {
42 return name === 'A' ? $$blockAStartIndex : name === 'B' ? $$blockBStartIndex : $$blockCStartIndex
43 }
44 if (prop === 'y') {
45 return name === 'A' ? $$blockAY : name === 'B' ? $$blockBY : $$blockCY
46 }
47 if (prop === 'el') {
48 return name === 'A' ? $$blockA : name === 'B' ? $$blockB : $$blockC
49 }
50 },
51 set: (name, prop, value) => {
52 if (prop === 'startIndex') {
53 if (name === 'A') $$blockAStartIndex = value
54 else if (name === 'B') $$blockBStartIndex = value
55 else $$blockCStartIndex = value
56 } else if (prop === 'y') {
57 if (name === 'A') $$blockAY = value
58 else if (name === 'B') $$blockBY = value
59 else $$blockCY = value
60 }
61 }
62 }
63
64 const loadBlock = (startIndex, blockId) => {
65 if (!el.id) {
66 throw new Error('[VirtualScroll] Component element must have an id attribute')
67 }
68 if (!$$validUrl) {
69 throw new Error('[VirtualScroll] url prop is required')
70 }
71
72 actions.post($$validUrl, {
73 requestCancellation: 'disabled',
74 payload: {
75 startIndex,
76 count: $$validBufferSize,
77 blockId: blockId === 'all' ? el.id : `${el.id}-${blockId}`,
78 componentId: el.id,
79 instanceNum: `${el.rocketInstanceId}`
80 }
81 })
82 }
83
84 const positionBlocks = () => {
85 const positions = ['A', 'B', 'C'].map(name => ({
86 block: name,
87 startIdx: blockState.get(name, 'startIndex'),
88 el: blockState.get(name, 'el'),
89 height: blockState.get(name, 'el').getBoundingClientRect().height
90 })).sort((a, b) => a.startIdx - b.startIdx)
91
92 const totalHeight = positions.reduce((sum, p) => sum + p.height, 0)
93
94 if ($$measuredItems > 0) {
95 const totalMeasured = $$totalMeasuredHeight + totalHeight
96 const totalItems = $$measuredItems + $$validBufferSize * 3
97 $$avgItemHeight = totalMeasured / totalItems
98 $$totalMeasuredHeight = totalMeasured
99 $$measuredItems = totalItems
100 } else {
101 $$avgItemHeight = totalHeight / ($$validBufferSize * 3)
102 $$totalMeasuredHeight = totalHeight
103 $$measuredItems = $$validBufferSize * 3
104 }
105
106 let currentY = positions[0].startIdx * $$avgItemHeight
107 for (const pos of positions) {
108 blockState.set(pos.block, 'y', currentY)
109 currentY += pos.height
110 }
111 }
112
113 const handleScroll = (direction) => {
114 if ($$isLoading) return
115
116 const [above, visible, below] = $$blockPositions
117 const isScrollingDown = direction === 'down'
118
119 const recycleBlock = isScrollingDown ? above : below
120 const referenceBlock = isScrollingDown ? below : above
121
122 const newStartIndex = blockState.get(referenceBlock, 'startIndex') + (isScrollingDown ? $$validBufferSize : -$$validBufferSize)
123
124 if (isScrollingDown ? newStartIndex >= $$totalItems : newStartIndex < 0) return
125
126 $$isLoading = true
127
128 blockState.set(recycleBlock, 'startIndex', newStartIndex)
129
130 $$blockPositions = isScrollingDown
131 ? [visible, below, above]
132 : [below, above, visible]
133
134 loadBlock(newStartIndex, recycleBlock.toLowerCase())
135
136 setTimeout(() => positionBlocks(), 100)
137
138 $$isLoading = false
139 }
140
141 const isBlockInView = (block, scrollTop, scrollBottom) =>
142 block.y < scrollBottom && (block.y + block.height) > scrollTop
143
144 let checkScroll = null
145
146 const clearJumpTimeout = () => {
147 if (jumpTimeout) {
148 clearTimeout(jumpTimeout)
149 jumpTimeout = null
150 }
151 }
152
153 const setupScrollHandler = () => {
154 let scrollTimeout
155 let lastProcessedScroll = 0
156
157 checkScroll = () => {
158 const now = Date.now()
159 if (now - lastProcessedScroll < 20) return
160 lastProcessedScroll = now
161
162 const scrollTop = $$viewport.scrollTop
163 const scrollBottom = scrollTop + $$viewport.clientHeight
164
165 const [above, visible, below] = $$blockPositions
166
167 const blocks = Object.fromEntries(
168 ['A', 'B', 'C'].map(name => [
169 name,
170 {
171 y: blockState.get(name, 'y'),
172 height: blockState.get(name, 'el').offsetHeight,
173 startIdx: blockState.get(name, 'startIndex')
174 }
175 ])
176 )
177
178 if (!['A', 'B', 'C'].some(name => isBlockInView(blocks[name], scrollTop, scrollBottom))) {
179 if ($$isLoading && jumpTimeout) {
180 clearJumpTimeout()
181 $$isLoading = false
182 }
183
184 if (!$$isLoading) {
185 clearJumpTimeout()
186
187 const baseIndex = Math.floor(Math.floor(scrollTop / $$avgItemHeight) / $$validBufferSize) * $$validBufferSize
188
189 $$blockAStartIndex = baseIndex - $$validBufferSize
190 $$blockBStartIndex = baseIndex
191 $$blockCStartIndex = baseIndex + $$validBufferSize
192
193 if ($$blockAStartIndex < 0) $$blockAStartIndex = 0
194 if ($$blockCStartIndex >= $$totalItems) $$blockCStartIndex = $$totalItems - $$validBufferSize
195
196 $$blockPositions = ['A', 'B', 'C']
197
198 $$isLoading = true
199 loadBlock($$blockAStartIndex, 'all')
200
201 jumpTimeout = setTimeout(() => {
202 positionBlocks()
203 $$isLoading = false
204 jumpTimeout = null
205 }, 250)
206
207 return
208 }
209 }
210
211 if (blocks[below] && ((scrollBottom > blocks[below].y + blocks[below].height - 100) || (scrollTop > blocks[below].y + blocks[below].height)) && !$$isLoading) {
212 handleScroll('down')
213 }
214
215 if (blocks[above] && ((scrollTop < blocks[above].y + 100) || (scrollBottom < blocks[above].y)) && !$$isLoading) {
216 handleScroll('up')
217 }
218 }
219
220 $$viewport.addEventListener('scroll', () => {
221 checkScroll()
222 clearTimeout(scrollTimeout)
223 scrollTimeout = setTimeout(checkScroll, 25)
224 })
225 }
226
227 const lastBlockContent = { A: '', B: '', C: '' }
228
229 const checkBlocksLoaded = () => {
230 if (['A', 'B', 'C'].some(name => {
231 const el = blockState.get(name, 'el')
232 const currentContent = el.innerHTML
233 const changed = currentContent !== lastBlockContent[name]
234 lastBlockContent[name] = currentContent
235 return changed
236 })) {
237 positionBlocks()
238
239 if (jumpTimeout) {
240 clearJumpTimeout()
241 $$isLoading = false
242 }
243
244 if (!hasInitializedScroll && $$viewport) {
245 setupScrollHandler()
246
247 if ($$validInitialIndex > 0 && $$viewport.scrollTop === 0) {
248 $$viewport.scrollTop = $$validInitialIndex * $$avgItemHeight
249 }
250
251 hasInitializedScroll = true
252 }
253 }
254 }
255
256 const observer = new MutationObserver(checkBlocksLoaded)
257 observer.observe(el, {
258 childList: true,
259 subtree: true,
260 characterData: true
261 })
262
263 effect(() => {
264 if (!el.id) {
265 throw new Error('[VirtualScroll] Component element must have an id attribute')
266 }
267 if ($$blockA.id && $$blockA.id !== '') return
268
269 $$blockA.id = `${el.id}-a`
270 $$blockB.id = `${el.id}-b`
271 $$blockC.id = `${el.id}-c`
272
273 $$viewport.style.height = (el.offsetHeight || 600) + 'px'
274
275 setTimeout(() => loadBlock($$blockAStartIndex, 'all'), 50)
276 })
277
278 onCleanup(() => {
279 observer.disconnect()
280 clearJumpTimeout()
281 })
282 </script>
283
284 <style>
285 .virtual-scroll-viewport {
286 height: inherit;
287 overflow-y: auto;
288 position: relative;
289 }
290
291 .virtual-scroll-spacer {
292 position: relative;
293 }
294
295 .virtual-scroll-block {
296 position: absolute;
297 top: 0;
298 left: 0;
299 right: 0;
300 }
301 </style>
302
303 <div class="virtual-scroll-viewport" data-ref="viewport">
304 <div class="virtual-scroll-spacer"
305 data-style="{height: $$scrollHeight + 'px'}">
306
307 <div class="virtual-scroll-block"
308 data-style="{transform: 'translateY(' + $$blockAY + 'px)'}"
309 data-ref="blockA">
310 </div>
311
312 <div class="virtual-scroll-block"
313 data-style="{transform: 'translateY(' + $$blockBY + 'px)'}"
314 data-ref="blockB">
315 </div>
316
317 <div class="virtual-scroll-block"
318 data-style="{transform: 'translateY(' + $$blockCY + 'px)'}"
319 data-ref="blockC">
320 </div>
321 </div>
322 </div>
323</template>