Rocket Virtual Scroll Pro
Rocket is currently in alpha – available with Datastar Pro.
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-props:url="string|required!|="
3 data-props:initial-index="int|=0"
4 data-props:buffer-size="int|=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 console.error('[VirtualScroll] Component element must have an id attribute')
67 return
68 }
69
70 actions.post($$validUrl, {
71 requestCancellation: 'disabled',
72 payload: {
73 startIndex,
74 count: $$validBufferSize,
75 blockId: blockId === 'all' ? el.id : `${el.id}-${blockId}`,
76 componentId: el.id,
77 instanceNum: `_${el.rocketInstanceId}`
78 }
79 })
80 }
81
82 const positionBlocks = () => {
83 const blockElems = ['A', 'B', 'C'].map(name => blockState.get(name, 'el'))
84
85 if (!blockElems.every(el => el)) return
86
87 const positions = ['A', 'B', 'C'].map(name => ({
88 block: name,
89 startIdx: blockState.get(name, 'startIndex'),
90 el: blockState.get(name, 'el'),
91 height: blockState.get(name, 'el').getBoundingClientRect().height
92 })).sort((a, b) => a.startIdx - b.startIdx)
93
94 const totalHeight = positions.reduce((sum, p) => sum + p.height, 0)
95
96 if ($$measuredItems > 0) {
97 const totalMeasured = $$totalMeasuredHeight + totalHeight
98 const totalItems = $$measuredItems + $$validBufferSize * 3
99 $$avgItemHeight = totalMeasured / totalItems
100 $$totalMeasuredHeight = totalMeasured
101 $$measuredItems = totalItems
102 } else {
103 $$avgItemHeight = totalHeight / ($$validBufferSize * 3)
104 $$totalMeasuredHeight = totalHeight
105 $$measuredItems = $$validBufferSize * 3
106 }
107
108 let currentY = positions[0].startIdx * $$avgItemHeight
109 for (const pos of positions) {
110 blockState.set(pos.block, 'y', currentY)
111 currentY += pos.height
112 }
113 }
114
115 const handleScroll = (direction) => {
116 if ($$isLoading) return
117
118 const [above, visible, below] = $$blockPositions
119 const isScrollingDown = direction === 'down'
120
121 const recycleBlock = isScrollingDown ? above : below
122 const referenceBlock = isScrollingDown ? below : above
123
124 const newStartIndex = blockState.get(referenceBlock, 'startIndex') + (isScrollingDown ? $$validBufferSize : -$$validBufferSize)
125
126 if (isScrollingDown ? newStartIndex >= $$totalItems : newStartIndex < 0) return
127
128 $$isLoading = true
129
130 blockState.set(recycleBlock, 'startIndex', newStartIndex)
131
132 $$blockPositions = isScrollingDown
133 ? [visible, below, above]
134 : [below, above, visible]
135
136 loadBlock(newStartIndex, recycleBlock.toLowerCase())
137
138 setTimeout(() => positionBlocks(), 100)
139
140 $$isLoading = false
141 }
142
143 const isBlockInView = (block, scrollTop, scrollBottom) =>
144 block.y < scrollBottom && (block.y + block.height) > scrollTop
145
146 let checkScroll = null
147
148 const clearJumpTimeout = () => {
149 if (jumpTimeout) {
150 clearTimeout(jumpTimeout)
151 jumpTimeout = null
152 }
153 }
154
155 const setupScrollHandler = () => {
156 if (!$$viewport) return
157
158 let scrollTimeout
159 let lastProcessedScroll = 0
160
161 checkScroll = () => {
162 const now = Date.now()
163 if (now - lastProcessedScroll < 20) return
164 lastProcessedScroll = now
165
166 const scrollTop = $$viewport.scrollTop
167 const scrollBottom = scrollTop + $$viewport.clientHeight
168
169 const [above, visible, below] = $$blockPositions
170
171 const blockElems = ['A', 'B', 'C'].map(name => blockState.get(name, 'el'))
172
173 if (!blockElems.every(el => el)) return
174
175 const blocks = Object.fromEntries(
176 ['A', 'B', 'C'].map(name => [
177 name,
178 {
179 y: blockState.get(name, 'y'),
180 height: blockState.get(name, 'el').offsetHeight,
181 startIdx: blockState.get(name, 'startIndex')
182 }
183 ])
184 )
185
186 if (!['A', 'B', 'C'].some(name => isBlockInView(blocks[name], scrollTop, scrollBottom))) {
187 if ($$isLoading && jumpTimeout) {
188 clearJumpTimeout()
189 $$isLoading = false
190 }
191
192 if (!$$isLoading) {
193 clearJumpTimeout()
194
195 const baseIndex = Math.floor(Math.floor(scrollTop / $$avgItemHeight) / $$validBufferSize) * $$validBufferSize
196
197 $$blockAStartIndex = baseIndex - $$validBufferSize
198 $$blockBStartIndex = baseIndex
199 $$blockCStartIndex = baseIndex + $$validBufferSize
200
201 if ($$blockAStartIndex < 0) $$blockAStartIndex = 0
202 if ($$blockCStartIndex >= $$totalItems) $$blockCStartIndex = $$totalItems - $$validBufferSize
203
204 $$blockPositions = ['A', 'B', 'C']
205
206 $$isLoading = true
207 loadBlock($$blockAStartIndex, 'all')
208
209 jumpTimeout = setTimeout(() => {
210 positionBlocks()
211 $$isLoading = false
212 jumpTimeout = null
213 }, 250)
214
215 return
216 }
217 }
218
219 if (blocks[below] && ((scrollBottom > blocks[below].y + blocks[below].height - 100) || (scrollTop > blocks[below].y + blocks[below].height)) && !$$isLoading) {
220 handleScroll('down')
221 }
222
223 if (blocks[above] && ((scrollTop < blocks[above].y + 100) || (scrollBottom < blocks[above].y)) && !$$isLoading) {
224 handleScroll('up')
225 }
226 }
227
228 $$viewport.addEventListener('scroll', () => {
229 checkScroll()
230 clearTimeout(scrollTimeout)
231 scrollTimeout = setTimeout(checkScroll, 25)
232 })
233 }
234
235 const lastBlockContent = { A: '', B: '', C: '' }
236
237 const checkBlocksLoaded = () => {
238 const blockElems = ['A', 'B', 'C'].map(name => blockState.get(name, 'el'))
239
240 if (!blockElems.every(el => el)) return
241
242 if (['A', 'B', 'C'].some(name => {
243 const el = blockState.get(name, 'el')
244 const currentContent = el.innerHTML
245 const changed = currentContent !== lastBlockContent[name]
246 lastBlockContent[name] = currentContent
247 return changed
248 })) {
249 positionBlocks()
250
251 if (jumpTimeout) {
252 clearJumpTimeout()
253 $$isLoading = false
254 }
255
256 if (!hasInitializedScroll && $$viewport) {
257 setupScrollHandler()
258
259 if ($$validInitialIndex > 0 && $$viewport.scrollTop === 0) {
260 $$viewport.scrollTop = $$validInitialIndex * $$avgItemHeight
261 }
262
263 hasInitializedScroll = true
264 }
265 }
266 }
267
268 const observer = new MutationObserver(checkBlocksLoaded)
269 observer.observe(el, {
270 childList: true,
271 subtree: true,
272 characterData: true
273 })
274
275 effect(() => {
276 if (!$$viewport || !$$blockA || !$$blockB || !$$blockC) return
277 if (!el.id) {
278 console.error('[VirtualScroll] Component element must have an id attribute', el)
279 return
280 }
281 if ($$blockA.id && $$blockA.id !== '') return
282
283 $$blockA.id = `${el.id}-a`
284 $$blockB.id = `${el.id}-b`
285 $$blockC.id = `${el.id}-c`
286
287 $$viewport.style.height = (el.offsetHeight || 600) + 'px'
288
289 setTimeout(() => loadBlock($$blockAStartIndex, 'all'), 50)
290 })
291
292 onCleanup(() => {
293 observer.disconnect()
294 clearJumpTimeout()
295 })
296 </script>
297
298 <style>
299 .virtual-scroll-viewport {
300 height: inherit;
301 overflow-y: auto;
302 position: relative;
303 }
304
305 .virtual-scroll-spacer {
306 position: relative;
307 }
308
309 .virtual-scroll-block {
310 position: absolute;
311 top: 0;
312 left: 0;
313 right: 0;
314 }
315 </style>
316
317 <div class="virtual-scroll-viewport" data-ref="viewport">
318 <div class="virtual-scroll-spacer"
319 data-style="{height: $$scrollHeight + 'px'}">
320
321 <div class="virtual-scroll-block"
322 data-style="{transform: 'translateY(' + $$blockAY + 'px)'}"
323 data-ref="blockA">
324 </div>
325
326 <div class="virtual-scroll-block"
327 data-style="{transform: 'translateY(' + $$blockBY + 'px)'}"
328 data-ref="blockB">
329 </div>
330
331 <div class="virtual-scroll-block"
332 data-style="{transform: 'translateY(' + $$blockCY + 'px)'}"
333 data-ref="blockC">
334 </div>
335 </div>
336 </div>
337</template>