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
38 const blockState = {
39 get: (name, prop) => {
40 if (prop === 'startIndex') {
41 return name === 'A' ? $$blockAStartIndex : name === 'B' ? $$blockBStartIndex : $$blockCStartIndex
42 }
43 if (prop === 'y') {
44 return name === 'A' ? $$blockAY : name === 'B' ? $$blockBY : $$blockCY
45 }
46 if (prop === 'el') {
47 return name === 'A' ? $$blockA : name === 'B' ? $$blockB : $$blockC
48 }
49 },
50 set: (name, prop, value) => {
51 if (prop === 'startIndex') {
52 if (name === 'A') $$blockAStartIndex = value
53 else if (name === 'B') $$blockBStartIndex = value
54 else $$blockCStartIndex = value
55 } else if (prop === 'y') {
56 if (name === 'A') $$blockAY = value
57 else if (name === 'B') $$blockBY = value
58 else $$blockCY = value
59 }
60 }
61 }
62
63 const loadBlock = (startIndex, blockId) => {
64 if (!el.id) {
65 console.error('[VirtualScroll] Component element must have an id attribute')
66 return
67 }
68
69 actions.post($$validUrl, {
70 requestCancellation: 'disabled',
71 payload: {
72 startIndex,
73 count: $$validBufferSize,
74 blockId: blockId === 'all' ? el.id : `${el.id}-${blockId}`,
75 componentId: el.id,
76 instanceNum: `_${el.rocketInstanceId}`
77 }
78 })
79 }
80
81 const positionBlocks = () => {
82 const blockElems = ['A', 'B', 'C'].map(name => blockState.get(name, 'el'))
83
84 if (!blockElems.every(el => el)) return
85
86 const positions = ['A', 'B', 'C'].map(name => ({
87 block: name,
88 startIdx: blockState.get(name, 'startIndex'),
89 el: blockState.get(name, 'el'),
90 height: blockState.get(name, 'el').getBoundingClientRect().height
91 })).sort((a, b) => a.startIdx - b.startIdx)
92
93 const totalHeight = positions.reduce((sum, p) => sum + p.height, 0)
94
95 if ($$measuredItems > 0) {
96 const totalMeasured = $$totalMeasuredHeight + totalHeight
97 const totalItems = $$measuredItems + $$validBufferSize * 3
98 $$avgItemHeight = totalMeasured / totalItems
99 $$totalMeasuredHeight = totalMeasured
100 $$measuredItems = totalItems
101 } else {
102 $$avgItemHeight = totalHeight / ($$validBufferSize * 3)
103 $$totalMeasuredHeight = totalHeight
104 $$measuredItems = $$validBufferSize * 3
105 }
106
107 let currentY = positions[0].startIdx * $$avgItemHeight
108 for (const pos of positions) {
109 blockState.set(pos.block, 'y', currentY)
110 currentY += pos.height
111 }
112 }
113
114 const handleScroll = (direction) => {
115 if ($$isLoading) return
116
117 const [above, visible, below] = $$blockPositions
118 const isScrollingDown = direction === 'down'
119
120 const recycleBlock = isScrollingDown ? above : below
121 const referenceBlock = isScrollingDown ? below : above
122
123 const newStartIndex = blockState.get(referenceBlock, 'startIndex') + (isScrollingDown ? $$validBufferSize : -$$validBufferSize)
124
125 if (isScrollingDown ? newStartIndex >= $$totalItems : newStartIndex < 0) return
126
127 $$isLoading = true
128
129 blockState.set(recycleBlock, 'startIndex', newStartIndex)
130
131 $$blockPositions = isScrollingDown
132 ? [visible, below, above]
133 : [below, above, visible]
134
135 loadBlock(newStartIndex, recycleBlock.toLowerCase())
136
137 setTimeout(() => positionBlocks(), 100)
138
139 $$isLoading = false
140 }
141
142 const isBlockInView = (block, scrollTop, scrollBottom) =>
143 block.y < scrollBottom && (block.y + block.height) > scrollTop
144
145 let checkScroll = null
146
147 const clearJumpTimeout = () => {
148 if (jumpTimeout) {
149 clearTimeout(jumpTimeout)
150 jumpTimeout = null
151 }
152 }
153
154 const setupScrollHandler = () => {
155 if (!$$viewport) return
156
157 let scrollTimeout
158 let lastProcessedScroll = 0
159
160 checkScroll = () => {
161 const now = Date.now()
162 if (now - lastProcessedScroll < 20) return
163 lastProcessedScroll = now
164
165 const scrollTop = $$viewport.scrollTop
166 const scrollBottom = scrollTop + $$viewport.clientHeight
167
168 const [above, visible, below] = $$blockPositions
169
170 const blockElems = ['A', 'B', 'C'].map(name => blockState.get(name, 'el'))
171
172 if (!blockElems.every(el => el)) return
173
174 const blocks = Object.fromEntries(
175 ['A', 'B', 'C'].map(name => [
176 name,
177 {
178 y: blockState.get(name, 'y'),
179 height: blockState.get(name, 'el').offsetHeight,
180 startIdx: blockState.get(name, 'startIndex')
181 }
182 ])
183 )
184
185 if (!['A', 'B', 'C'].some(name => isBlockInView(blocks[name], scrollTop, scrollBottom))) {
186 if ($$isLoading && jumpTimeout) {
187 clearJumpTimeout()
188 $$isLoading = false
189 }
190
191 if (!$$isLoading) {
192 clearJumpTimeout()
193
194 const baseIndex = Math.floor(Math.floor(scrollTop / $$avgItemHeight) / $$validBufferSize) * $$validBufferSize
195
196 $$blockAStartIndex = baseIndex - $$validBufferSize
197 $$blockBStartIndex = baseIndex
198 $$blockCStartIndex = baseIndex + $$validBufferSize
199
200 if ($$blockAStartIndex < 0) $$blockAStartIndex = 0
201 if ($$blockCStartIndex >= $$totalItems) $$blockCStartIndex = $$totalItems - $$validBufferSize
202
203 $$blockPositions = ['A', 'B', 'C']
204
205 $$isLoading = true
206 loadBlock($$blockAStartIndex, 'all')
207
208 jumpTimeout = setTimeout(() => {
209 positionBlocks()
210 $$isLoading = false
211 jumpTimeout = null
212 }, 250)
213
214 return
215 }
216 }
217
218 if (blocks[below] && ((scrollBottom > blocks[below].y + blocks[below].height - 100) || (scrollTop > blocks[below].y + blocks[below].height)) && !$$isLoading) {
219 handleScroll('down')
220 }
221
222 if (blocks[above] && ((scrollTop < blocks[above].y + 100) || (scrollBottom < blocks[above].y)) && !$$isLoading) {
223 handleScroll('up')
224 }
225 }
226
227 $$viewport.addEventListener('scroll', () => {
228 checkScroll()
229 clearTimeout(scrollTimeout)
230 scrollTimeout = setTimeout(checkScroll, 25)
231 })
232 }
233
234 const lastBlockContent = { A: '', B: '', C: '' }
235
236 const checkBlocksLoaded = () => {
237 const blockElems = ['A', 'B', 'C'].map(name => blockState.get(name, 'el'))
238
239 if (!blockElems.every(el => el)) return
240
241 if (['A', 'B', 'C'].some(name => {
242 const el = blockState.get(name, 'el')
243 const currentContent = el.innerHTML
244 const changed = currentContent !== lastBlockContent[name]
245 lastBlockContent[name] = currentContent
246 return changed
247 })) {
248 positionBlocks()
249
250 if (jumpTimeout) {
251 clearJumpTimeout()
252 $$isLoading = false
253 }
254 }
255 }
256
257 const observer = new MutationObserver(checkBlocksLoaded)
258 observer.observe(el, {
259 childList: true,
260 subtree: true,
261 characterData: true
262 })
263
264 effect(() => {
265 if (!$$viewport || !$$blockA || !$$blockB || !$$blockC) return
266 if (!el.id) {
267 console.error('[VirtualScroll] Component element must have an id attribute', el)
268 return
269 }
270 if ($$blockA.id && $$blockA.id !== '') return
271
272 $$blockA.id = `${el.id}-a`
273 $$blockB.id = `${el.id}-b`
274 $$blockC.id = `${el.id}-c`
275
276 $$viewport.style.height = (el.offsetHeight || 600) + 'px'
277
278 if ($$avgItemHeight === 0) {
279 $$avgItemHeight = 50
280 }
281
282 setTimeout(() => loadBlock($$blockAStartIndex, 'all'), 50)
283 })
284
285 setTimeout(() => {
286 positionBlocks()
287 setupScrollHandler()
288
289 $$viewport.scrollTop = $$validInitialIndex * $$avgItemHeight
290 }, 100)
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>