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