Rocket Virtual Scroll Pro

Rocket is currently in alpha – available with Datastar Pro.

Demo

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:

Usage Example #

1<rocket-virtual-scroll
2    id="my-rocket-virtual-scroll"
3    data-attr:url="'/examples/rocket_virtual_scroll/items'"
4    data-attr:initial-index="100"
5    data-attr:buffer-size="20"
6></rocket-virtual-scroll>

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>