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
 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>