Rocket Virtual Scroll Pro

Rocket is currently in alpha – available in the Datastar Pro repo.

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="20"
5    data-attr:buffer-size="20"
6></rocket-virtual-scroll>

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>