Skip to content

Instantly share code, notes, and snippets.

@ypresto
Last active April 12, 2024 11:30
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ypresto/d4ebfa7e573f0b659dc243901add38c6 to your computer and use it in GitHub Desktop.
Save ypresto/d4ebfa7e573f0b659dc243901add38c6 to your computer and use it in GitHub Desktop.
Reuse rows rendered by @tanstack/virtual to optimize for complicated row component with slow mount (React / Vue)
// Not tested!
import type { VirtualItem, Virtualizer } from '@tanstack/virtual'
import { useEffect, useRef, useState } from 'react'
const DEBUG = false
export interface PooledVirtualItem extends Omit<VirtualItem, 'key'> {
key: number
suspended?: boolean
}
function createObjectPool<T>() {
const pool: T[] = []
return {
add: (item: T) => {
pool.push(item)
},
take: () => {
return pool.pop()
},
items: () => {
return pool
},
drain: (size: number) => {
const overflow = pool.length - size
if (DEBUG && overflow > 0) console.debug('usePooledVirtualItems: draining ' + overflow + ' items')
for (let i = 0; i < overflow; i++) {
pool.pop()
}
},
}
}
export function usePooledVirtualItems<T extends Element>(
virtualizer: Virtualizer<any, T>,
options?: { maxExtraItems?: number; sort?: boolean }
) {
const poolRef = useRef(createObjectPool<PooledVirtualItem>())
const prevItemsRef = useRef<VirtualItem[]>([])
const nextRowIdRef = useRef(0)
const keyToRowIdMapRef = useRef<Map<string | number, number> | null>(null)
const [items, setItems] = useState(virtualizer.getVirtualItems())
useEffect(() => {
const pool = poolRef.current
const prevItems = prevItemsRef.current
const virtualItems = virtualizer.getVirtualItems()
if (!keyToRowIdMapRef.current) {
keyToRowIdMapRef.current = new Map(virtualItems.map((item, i) => [item.key, i]))
nextRowIdRef.current = virtualItems.length
if (DEBUG) console.debug('usePooledVirtualItems: initial row count: ' + nextRowIdRef.current)
}
const keyToRowIdMap = keyToRowIdMapRef.current
const visibleItemKeys = virtualItems.map(item => item.key)
const disappeared = prevItems.filter(prevItem => !visibleItemKeys.includes(prevItem.key))
for (const item of disappeared) {
pool.add({ ...item, key: keyToRowIdMap.get(item.key)!, suspended: true })
keyToRowIdMap.delete(item.key)
}
for (const item of virtualItems) {
if (!keyToRowIdMap.has(item.key)) {
const reused = pool.take()
if (DEBUG && !reused) console.debug('usePooledVirtualItems: creating row id: ' + nextRowIdRef.current)
keyToRowIdMap.set(item.key, reused ? reused.key : nextRowIdRef.current++)
}
}
if (options?.maxExtraItems != null) {
pool.drain(options?.maxExtraItems)
}
prevItemsRef.current = virtualItems
const result = [
...virtualItems.map((item): PooledVirtualItem => ({ ...item, key: keyToRowIdMap.get(item.key)! })),
...pool.items(),
]
if (options?.sort) {
result.sort((a, b) => a.key - b.key)
}
setItems(result)
}, [virtualizer])
return items
}
import type { VirtualItem, Virtualizer } from '@tanstack/vue-virtual'
import { computed, type Ref } from 'vue'
const DEBUG = false
export interface PooledVirtualItem extends Omit<VirtualItem, 'key'> {
key: number
suspended?: boolean
}
function createObjectPool<T>() {
const pool: T[] = []
return {
add: (item: T) => {
pool.push(item)
},
take: () => {
return pool.pop()
},
items: () => {
return pool
},
drain: (size: number) => {
const overflow = pool.length - size
if (DEBUG && overflow > 0) console.debug('usePooledVirtualItems: draining ' + overflow + ' items')
for (let i = 0; i < overflow; i++) {
pool.pop()
}
},
}
}
export function usePooledVirtualItems<T extends Element>(
virtualizer: Ref<Virtualizer<any, T>>,
options?: { maxExtraItems?: number; sort?: boolean }
) {
const pool = createObjectPool<PooledVirtualItem>()
let prevItems: VirtualItem[] = []
let nextRowId = 0
let keyToRowIdMap: Map<string | number, number>
return computed(() => {
const virtualItems = virtualizer.value.getVirtualItems()
if (!keyToRowIdMap) {
keyToRowIdMap = new Map(virtualItems.map((item, i) => [item.key, i]))
nextRowId = virtualItems.length
if (DEBUG) console.debug('usePooledVirtualItems: initial row count: ' + nextRowId)
}
const visibleItemKeys = virtualItems.map(item => item.key)
const disappeared = prevItems.filter(prevItem => !visibleItemKeys.includes(prevItem.key))
for (const item of disappeared) {
pool.add({ ...item, key: keyToRowIdMap.get(item.key)!, suspended: true })
keyToRowIdMap.delete(item.key)
}
for (const item of virtualItems) {
if (!keyToRowIdMap.has(item.key)) {
const reused = pool.take()
if (DEBUG && !reused) console.debug('usePooledVirtualItems: creating row id: ' + nextRowId)
keyToRowIdMap.set(item.key, reused ? reused.key : nextRowId++)
}
}
if (options?.maxExtraItems != null) {
pool.drain(options?.maxExtraItems)
}
prevItems = virtualItems
const result = [
...virtualItems.map((item): PooledVirtualItem => ({ ...item, key: keyToRowIdMap.get(item.key)! })),
...pool.items(),
]
if (options?.sort) {
result.sort((a, b) => a.key - b.key)
}
return result
})
}
<!-- Not tested! -->
<template>
<div
ref="wrapperElement"
:style="{
height: `${totalSize}px`,
width: '100%',
position: 'relative',
}"
>
<div
:style="{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRows[0]?.start ?? 0}px)`,
}"
>
<div
v-for="virtualRow in pooledVirtualRows"
:key="virtualRow.key"
:data-index="virtualRow.index"
:hidden="virtualRow.suspended"
>
Row {{ virtualRow.index }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, toRefs, ref, type PropType } from 'vue'
import { useVirtualizer } from '@tanstack/vue-virtual'
import { usePooledVirtualItems } from '~/utils/usePooledVirtualItems'
const props = defineProps({
items: {
type: Array as PropType<string[]>,
required: true,
},
})
const { items } = toRefs(props)
const wrapperElement = ref<HTMLElement | null>(null)
const rowVirtualizer = useVirtualizer(
computed(() => {
return {
count: items.length,
getScrollElement: () => wrapperElement.value,
estimateSize: () => 120,
overscan: 0,
}
})
)
const virtualRows = computed(() => rowVirtualizer.value.getVirtualItems())
const pooledVirtualRows = usePooledVirtualItems(rowVirtualizer)
const totalSize = computed(() => rowVirtualizer.value.getTotalSize())
</script>
<!-- With sort: true option it does not change order of items even it is suspended. -->
<!-- This removes DOM mutation for moving element in container element (insertBefore()). -->
<!-- You should specify order: style because DOM order does not match with actual list order. -->
<!-- Not tested! -->
<template>
<div
ref="wrapperElement"
:style="{
height: `${totalSize}px`,
width: '100%',
position: 'relative',
}"
>
<div
:style="{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRows[0]?.start ?? 0}px)`,
display: 'flex',
flexDirection: 'column',
}"
>
<div
v-for="virtualRow in pooledVirtualRows"
:key="virtualRow.key"
:data-index="virtualRow.index"
:hidden="virtualRow.suspended"
:style="{ order: virtualRow.index }"
>
Row {{ virtualRow.index }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, toRefs, ref, type PropType } from 'vue'
import { useVirtualizer } from '@tanstack/vue-virtual'
import { usePooledVirtualItems } from '~/utils/usePooledVirtualItems'
const props = defineProps({
items: {
type: Array as PropType<string[]>,
required: true,
},
})
const { items } = toRefs(props)
const wrapperElement = ref<HTMLElement | null>(null)
const rowVirtualizer = useVirtualizer(
computed(() => {
return {
count: items.length,
getScrollElement: () => wrapperElement.value,
estimateSize: () => 120,
overscan: 0,
}
})
)
const virtualRows = computed(() => rowVirtualizer.value.getVirtualItems())
const pooledVirtualRows = usePooledVirtualItems(rowVirtualizer, { sort: true })
const totalSize = computed(() => rowVirtualizer.value.getTotalSize())
</script>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment