Rocket Virtual Scroll Example

  1import { rocket } from 'datastar'
  2
  3rocket('virtual-scroll', {
  4  mode: 'light',
  5  props: ({ string, number }) => ({
  6    url: string,
  7    initialIndex: number.min(0).default(20),
  8    bufferSize: number.min(1).default(50),
  9    totalItems: number.min(1).default(100000),
 10  }),
 11  onFirstRender({ cleanup, host, observeProps, props, refs }) {
 12    /** @type {HTMLElement | null} */
 13    let viewport
 14    /** @type {HTMLElement | null} */
 15    let spacer
 16    /** @type {{ A: HTMLElement | null, B: HTMLElement | null, C: HTMLElement | null }} */
 17    let blocks = { A: null, B: null, C: null }
 18    let blockAStartIndex = 0
 19    let blockBStartIndex = 0
 20    let blockCStartIndex = 0
 21    let blockAY = 0
 22    let blockBY = 0
 23    let blockCY = 0
 24    let avgItemHeight = 50
 25    let scrollHeight = 0
 26    let isLoading = false
 27    let blockPositions = ['A', 'B', 'C']
 28    let measuredItems = 0
 29    let totalMeasuredHeight = 0
 30    let jumpTimeout = 0
 31    let hasInitializedScroll = false
 32    let lastProcessedScroll = 0
 33    let scrollTimeout = 0
 34    let observer
 35    const lastBlockContent = { A: '', B: '', C: '' }
 36    const setHeights = () => {
 37      scrollHeight = props.totalItems * avgItemHeight
 38      if (spacer) spacer.style.height = `${scrollHeight}px`
 39    }
 40
 41    const startIndexOf = (name) =>
 42      name === 'A'
 43        ? blockAStartIndex
 44        : name === 'B'
 45          ? blockBStartIndex
 46          : blockCStartIndex
 47
 48    const setStartIndex = (name, value) => {
 49      if (name === 'A') blockAStartIndex = value
 50      else if (name === 'B') blockBStartIndex = value
 51      else blockCStartIndex = value
 52    }
 53
 54    const setY = (name, value) => {
 55      if (name === 'A') blockAY = value
 56      else if (name === 'B') blockBY = value
 57      else blockCY = value
 58      blocks[name]?.style.setProperty('transform', `translateY(${value}px)`)
 59    }
 60
 61    const clearJumpTimeout = () => {
 62      if (!jumpTimeout) return
 63      clearTimeout(jumpTimeout)
 64      jumpTimeout = 0
 65    }
 66
 67    const loadBlock = async (startIndex, blockId) => {
 68      if (!host.id) {
 69        throw new Error(
 70          '[IonVirtualScroll] Component element must have an id attribute',
 71        )
 72      }
 73      if (!props.url) {
 74        throw new Error('[IonVirtualScroll] url prop is required')
 75      }
 76      const response = await fetch(props.url, {
 77        method: 'POST',
 78        headers: {
 79          Accept: 'text/event-stream, text/html, application/json',
 80          'Content-Type': 'application/json',
 81          'Datastar-Request': 'true',
 82        },
 83        body: JSON.stringify({
 84          startIndex,
 85          count: props.bufferSize,
 86          blockId: blockId === 'all' ? host.id : `${host.id}-${blockId}`,
 87          componentId: host.id,
 88          instanceNum:
 89            host.getAttribute('rocket-instance-id') ??
 90            /** @type {HTMLElement & { rocketInstanceId?: string }} */ (host)
 91              .rocketInstanceId ??
 92            '',
 93        }),
 94      })
 95      if (!response.body) return
 96      const reader = response.body.getReader()
 97      const decoder = new TextDecoder()
 98      let buffer = ''
 99
100      const flush = (chunk) => {
101        let event = 'message'
102        const data = []
103        for (const line of chunk.split('\n')) {
104          if (line.startsWith('event:')) event = line.slice(6).trim()
105          else if (line.startsWith('data:'))
106            data.push(line.slice(5).trimStart())
107        }
108        if (!event.startsWith('datastar')) return
109        const argsRawLines = {}
110        for (const line of data.join('\n').split('\n')) {
111          const i = line.indexOf(' ')
112          if (i < 0) continue
113          const key = line.slice(0, i)
114          const value = line.slice(i + 1)
115          ;(argsRawLines[key] ??= []).push(value)
116        }
117        document.dispatchEvent(
118          new CustomEvent('datastar-fetch', {
119            detail: {
120              type: event,
121              el: host,
122              argsRaw: Object.fromEntries(
123                Object.entries(argsRawLines).map(([key, values]) => [
124                  key,
125                  values.join('\n'),
126                ]),
127              ),
128            },
129          }),
130        )
131      }
132
133      while (true) {
134        const { done, value } = await reader.read()
135        buffer += decoder.decode(value ?? new Uint8Array(), {
136          stream: !done,
137        })
138        let boundary = buffer.indexOf('\n\n')
139        while (boundary >= 0) {
140          flush(buffer.slice(0, boundary))
141          buffer = buffer.slice(boundary + 2)
142          boundary = buffer.indexOf('\n\n')
143        }
144        if (done) {
145          if (buffer) flush(buffer)
146          break
147        }
148      }
149    }
150
151    const positionBlocks = () => {
152      const positions = ['A', 'B', 'C']
153        .map((name) => ({
154          block: name,
155          startIdx: startIndexOf(name),
156          el: blocks[name],
157          height: blocks[name]?.getBoundingClientRect().height ?? 0,
158        }))
159        .sort((a, b) => a.startIdx - b.startIdx)
160      const totalHeight = positions.reduce((sum, pos) => sum + pos.height, 0)
161      const blockCount = props.bufferSize * 3
162      totalMeasuredHeight =
163        measuredItems > 0 ? totalMeasuredHeight + totalHeight : totalHeight
164      measuredItems =
165        measuredItems > 0 ? measuredItems + blockCount : blockCount
166      avgItemHeight = totalMeasuredHeight / measuredItems || avgItemHeight
167
168      let currentY = (positions[0]?.startIdx ?? 0) * avgItemHeight
169      for (const pos of positions) {
170        setY(pos.block, currentY)
171        currentY += pos.height
172      }
173      setHeights()
174    }
175
176    const handleScroll = (direction) => {
177      if (isLoading) return
178      const [above, visible, below] = blockPositions
179      const recycleBlock = direction === 'down' ? above : below
180      const referenceBlock = direction === 'down' ? below : above
181      const newStartIndex =
182        startIndexOf(referenceBlock) +
183        (direction === 'down' ? props.bufferSize : -props.bufferSize)
184
185      if (
186        (direction === 'down' && newStartIndex >= props.totalItems) ||
187        (direction === 'up' && newStartIndex < 0)
188      ) {
189        return
190      }
191
192      isLoading = true
193      setStartIndex(recycleBlock, newStartIndex)
194      blockPositions =
195        direction === 'down' ? [visible, below, above] : [below, above, visible]
196      loadBlock(newStartIndex, recycleBlock.toLowerCase())
197      setTimeout(positionBlocks, 100)
198      isLoading = false
199    }
200
201    const isBlockInView = (block, top, bottom) =>
202      block.y < bottom && block.y + block.height > top
203
204    const checkScroll = () => {
205      if (!viewport) return
206      const now = Date.now()
207      if (now - lastProcessedScroll < 20) return
208      lastProcessedScroll = now
209
210      const { scrollTop, clientHeight } = viewport
211      const scrollBottom = scrollTop + clientHeight
212      const y = { A: blockAY, B: blockBY, C: blockCY }
213      const [above, , below] = blockPositions
214      const nextBlocks = Object.fromEntries(
215        ['A', 'B', 'C'].map((name) => [
216          name,
217          {
218            y: y[name],
219            height: blocks[name]?.offsetHeight ?? 0,
220            startIdx: startIndexOf(name),
221          },
222        ]),
223      )
224
225      if (
226        !['A', 'B', 'C'].some((name) =>
227          isBlockInView(nextBlocks[name], scrollTop, scrollBottom),
228        )
229      ) {
230        if (isLoading && jumpTimeout) {
231          clearJumpTimeout()
232          isLoading = false
233        }
234
235        if (!isLoading) {
236          clearJumpTimeout()
237          const baseIndex =
238            Math.floor(
239              Math.floor(scrollTop / avgItemHeight) / props.bufferSize,
240            ) * props.bufferSize
241
242          blockAStartIndex = Math.max(0, baseIndex - props.bufferSize)
243          blockBStartIndex = baseIndex
244          blockCStartIndex = Math.min(
245            props.totalItems - props.bufferSize,
246            baseIndex + props.bufferSize,
247          )
248          blockPositions = ['A', 'B', 'C']
249          isLoading = true
250          loadBlock(blockAStartIndex, 'all')
251          jumpTimeout = setTimeout(() => {
252            positionBlocks()
253            isLoading = false
254            jumpTimeout = 0
255          }, 250)
256          return
257        }
258      }
259
260      if (
261        nextBlocks[below] &&
262        (scrollBottom > nextBlocks[below].y + nextBlocks[below].height - 100 ||
263          scrollTop > nextBlocks[below].y + nextBlocks[below].height) &&
264        !isLoading
265      ) {
266        handleScroll('down')
267      }
268
269      if (
270        nextBlocks[above] &&
271        (scrollTop < nextBlocks[above].y + 100 ||
272          scrollBottom < nextBlocks[above].y) &&
273        !isLoading
274      ) {
275        handleScroll('up')
276      }
277    }
278
279    const checkBlocksLoaded = () => {
280      if (
281        !['A', 'B', 'C'].some((name) => {
282          const html = blocks[name]?.innerHTML ?? ''
283          const changed = html !== lastBlockContent[name]
284          lastBlockContent[name] = html
285          return changed
286        })
287      ) {
288        return
289      }
290
291      positionBlocks()
292      if (jumpTimeout) {
293        clearJumpTimeout()
294        isLoading = false
295      }
296      if (!hasInitializedScroll && viewport) {
297        viewport.addEventListener('scroll', () => {
298          checkScroll()
299          clearTimeout(scrollTimeout)
300          scrollTimeout = setTimeout(checkScroll, 25)
301        })
302        if (props.initialIndex > 0 && viewport.scrollTop === 0) {
303          viewport.scrollTop = props.initialIndex * avgItemHeight
304        }
305        hasInitializedScroll = true
306      }
307    }
308
309    const reset = () => {
310      if (
311        !viewport ||
312        !spacer ||
313        !blocks.A ||
314        !blocks.B ||
315        !blocks.C ||
316        !host.id
317      ) {
318        return
319      }
320      blockAStartIndex = Math.max(0, props.initialIndex - props.bufferSize)
321      blockBStartIndex = props.initialIndex
322      blockCStartIndex = props.initialIndex + props.bufferSize
323      blockAY = 0
324      blockBY = 0
325      blockCY = 0
326      avgItemHeight = 50
327      scrollHeight = 0
328      isLoading = false
329      blockPositions = ['A', 'B', 'C']
330      measuredItems = 0
331      totalMeasuredHeight = 0
332      hasInitializedScroll = false
333      lastProcessedScroll = 0
334      clearJumpTimeout()
335      spacer.style.height = '0px'
336      for (const name of ['A', 'B', 'C']) {
337        blocks[name]?.replaceChildren()
338        blocks[name]?.style.setProperty('transform', 'translateY(0px)')
339        lastBlockContent[name] = ''
340      }
341      viewport.scrollTop = props.initialIndex * avgItemHeight
342      viewport.style.height = `${host.offsetHeight || 600}px`
343      setHeights()
344      loadBlock(blockAStartIndex, 'all')
345    }
346
347    const init = () => {
348      viewport = /** @type {HTMLElement | null} */ (refs.viewport)
349      spacer = /** @type {HTMLElement | null} */ (refs.spacer)
350      blocks = {
351        A: /** @type {HTMLElement | null} */ (refs.blockA),
352        B: /** @type {HTMLElement | null} */ (refs.blockB),
353        C: /** @type {HTMLElement | null} */ (refs.blockC),
354      }
355      if (!viewport || !spacer || !blocks.A || !blocks.B || !blocks.C) return
356
357      viewport.style.height = `${host.offsetHeight || 600}px`
358      observer?.disconnect()
359      observer = new MutationObserver(checkBlocksLoaded)
360      observer.observe(host, {
361        childList: true,
362        subtree: true,
363        characterData: true,
364      })
365      setTimeout(reset, 50)
366    }
367
368    init()
369
370    observeProps(reset)
371
372    cleanup(() => {
373      observer?.disconnect()
374      clearJumpTimeout()
375      clearTimeout(scrollTimeout)
376    })
377  },
378  render: ({ html, host }) => html`
379    <style>
380      :host {
381        display: block;
382      }
383
384      .virtual-scroll-viewport {
385        height: inherit;
386        overflow-y: auto;
387        position: relative;
388      }
389
390      .virtual-scroll-spacer {
391        position: relative;
392      }
393
394      .virtual-scroll-block {
395        position: absolute;
396        top: 0;
397        left: 0;
398        right: 0;
399      }
400    </style>
401
402    <div class="virtual-scroll-viewport" data-ref:viewport data-viewport>
403      <div class="virtual-scroll-spacer" data-ref:spacer data-spacer>
404        <div
405          id="${host.id}-a"
406          class="virtual-scroll-block"
407          data-ref:block-a
408          data-block="A"
409        ></div>
410        <div
411          id="${host.id}-b"
412          class="virtual-scroll-block"
413          data-ref:block-b
414          data-block="B"
415        ></div>
416        <div
417          id="${host.id}-c"
418          class="virtual-scroll-block"
419          data-ref:block-c
420          data-block="C"
421        ></div>
422      </div>
423    </div>
424  `,
425})