import type { VirtualItem, VirtualizerOptions, VirtualizerState } from './types'
type VirtualizerListener = () => void
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max)
}
function sameOptions(a: VirtualizerOptions, b: VirtualizerOptions) {
return (
a.count === b.count &&
a.estimateSize === b.estimateSize &&
(a.overscan ?? 6) === (b.overscan ?? 6) &&
a.viewportHeight === b.viewportHeight &&
(a.scrollOffset ?? 0) === (b.scrollOffset ?? 0)
)
}
function sameState(a: VirtualizerState, b: VirtualizerState) {
if (
a.totalSize !== b.totalSize ||
a.startIndex !== b.startIndex ||
a.endIndex !== b.endIndex ||
a.viewportHeight !== b.viewportHeight ||
a.items.length !== b.items.length
) {
return false
}
// Per-item start+size check: catches column-resize cases where the total
// is unchanged (e.g. column A grew, column B shrunk to compensate under
// fit-to-width). Without this, the virtualizer skips the emit and the
// grid renders with stale widths.
for (let i = 0; i < a.items.length; i += 1) {
const ai = a.items[i]!
const bi = b.items[i]!
if (ai.index !== bi.index || ai.start !== bi.start || ai.size !== bi.size) return false
}
return true
}
// Slot-based keying: the key is the POSITION of the item inside the
// visible window (slot 0 = topmost rendered row, slot 1 = next, etc.)
// - NOT the data index. As the user scrolls, the window's startIndex
// changes but the slot keys stay the same (0..N-1), so Svelte recycles
// the existing
DOM nodes and only updates their data + position.
//
// This avoids the mount/unmount churn that previously fired on every
// scroll tick: with data-index keys, scrolling down 1 row meant the old
// topmost key disappeared and a new bottommost key appeared, forcing
// Svelte to unmount the top
and mount a fresh one at the bottom.
// Slot-based keys keep the same N
nodes alive for the lifetime of
// the grid; scroll just translates them via the top spacer.
function buildUniformItems(
startIndex: number,
endIndex: number,
estimateSize: number,
): Array {
const items: Array = []
for (let index = startIndex; index <= endIndex; index += 1) {
const start = index * estimateSize
items.push({
index,
start,
size: estimateSize,
end: start + estimateSize,
key: `virtual_slot_${index - startIndex}`,
})
}
return items
}
function buildVariableItems(
startIndex: number,
endIndex: number,
offsets: Array,
): Array {
const items: Array = []
for (let index = startIndex; index <= endIndex; index += 1) {
const start = offsets[index] ?? 0
const end = offsets[index + 1] ?? start
items.push({
index,
start,
size: end - start,
end,
key: `virtual_slot_${index - startIndex}`,
})
}
return items
}
function createState(
options: VirtualizerOptions,
/** Pre-built cumulative offsets for the variable-size path. */
offsets: Array | null,
): VirtualizerState {
const count = Math.max(options.count, 0)
const overscan = Math.max(options.overscan ?? 6, 0)
const viewportHeight = Math.max(options.viewportHeight, 0)
if (typeof options.estimateSize === 'function' && offsets) {
const totalSize = offsets[count] ?? 0
const maxOffset = Math.max(totalSize - viewportHeight, 0)
const scrollOffset = clamp(options.scrollOffset ?? 0, 0, maxOffset)
// First index whose end > scrollOffset.
let lo = 0
let hi = count
while (lo < hi) {
const mid = (lo + hi) >>> 1
if ((offsets[mid + 1] ?? 0) <= scrollOffset) lo = mid + 1
else hi = mid
}
const visibleStart = lo
// First index whose start >= scrollOffset + viewportHeight.
const viewportEnd = scrollOffset + viewportHeight
lo = visibleStart
hi = count
while (lo < hi) {
const mid = (lo + hi) >>> 1
if ((offsets[mid] ?? 0) < viewportEnd) lo = mid + 1
else hi = mid
}
const visibleEnd = Math.max(lo - 1, visibleStart)
const startIndex = count === 0 ? 0 : clamp(visibleStart - overscan, 0, count - 1)
const endIndex = count === 0 ? -1 : clamp(visibleEnd + overscan, 0, count - 1)
return {
items: endIndex >= startIndex ? buildVariableItems(startIndex, endIndex, offsets) : [],
totalSize,
startIndex,
endIndex,
scrollOffset,
viewportHeight,
}
}
// Uniform-size fast path (original behavior).
const estimateSize = Math.max(
typeof options.estimateSize === 'number' ? options.estimateSize : 1,
1,
)
const totalSize = count * estimateSize
const maxOffset = Math.max(totalSize - viewportHeight, 0)
const scrollOffset = clamp(options.scrollOffset ?? 0, 0, maxOffset)
const visibleStart = Math.floor(scrollOffset / estimateSize)
const visibleCount = Math.ceil(viewportHeight / estimateSize)
const visibleEnd = Math.min(visibleStart + visibleCount, Math.max(count - 1, 0))
const startIndex = count === 0 ? 0 : clamp(visibleStart - overscan, 0, count - 1)
const endIndex = count === 0 ? -1 : clamp(visibleEnd + overscan, 0, count - 1)
return {
items: endIndex >= startIndex ? buildUniformItems(startIndex, endIndex, estimateSize) : [],
totalSize,
startIndex,
endIndex,
scrollOffset,
viewportHeight,
}
}
/** Cached cumulative-offset table for the variable-size virtualizer path.
* Rebuilt only when count or the size function changes; reused across the
* many `recalc()` calls that happen during scroll. */
type OffsetCache = {
fn: (index: number) => number
count: number
offsets: Array
} | null
export function createVirtualizer(initial: VirtualizerOptions) {
let options = initial
let offsetCache: OffsetCache = null
function getOffsets(): Array | null {
if (typeof options.estimateSize !== 'function') return null
const count = Math.max(options.count, 0)
if (
offsetCache &&
offsetCache.fn === options.estimateSize &&
offsetCache.count === count
) {
return offsetCache.offsets
}
const sizeFn = options.estimateSize
const offsets = new Array(count + 1)
offsets[0] = 0
for (let i = 0; i < count; i += 1) {
offsets[i + 1] = offsets[i]! + Math.max(sizeFn(i), 1)
}
offsetCache = { fn: sizeFn, count, offsets }
return offsets
}
let state = createState(options, getOffsets())
const listeners = new Set()
function emit() {
listeners.forEach((listener) => listener())
}
function recalc() {
const next = createState(options, getOffsets())
if (sameState(state, next)) return
state = next
emit()
}
return {
setOptions(next: Partial) {
const merged = { ...options, ...next }
if (sameOptions(options, merged)) return
options = merged
recalc()
},
setScrollOffset(scrollOffset: number) {
if ((options.scrollOffset ?? 0) === scrollOffset) return
options = { ...options, scrollOffset }
recalc()
},
setViewportHeight(viewportHeight: number) {
if (options.viewportHeight === viewportHeight) return
options = { ...options, viewportHeight }
recalc()
},
scrollToIndex(index: number) {
const boundedIndex = clamp(index, 0, Math.max(options.count - 1, 0))
// Read the current state's totals so the offset is correct under both
// uniform and per-index sizing.
const totalSize = state.totalSize
let targetOffset: number
if (typeof options.estimateSize === 'function') {
// Use the cached cumulative offsets if present.
const offsets = getOffsets()
if (offsets) {
targetOffset = offsets[boundedIndex] ?? 0
} else {
let acc = 0
const sizeFn = options.estimateSize
for (let i = 0; i < boundedIndex; i += 1) acc += Math.max(sizeFn(i), 1)
targetOffset = acc
}
} else {
targetOffset = boundedIndex * Math.max(options.estimateSize, 1)
}
const maxOffset = Math.max(totalSize - options.viewportHeight, 0)
const nextOffset = clamp(targetOffset, 0, maxOffset)
if ((options.scrollOffset ?? 0) === nextOffset) return
options = { ...options, scrollOffset: nextOffset }
recalc()
},
getVirtualItems() {
return state.items
},
getTotalSize() {
return state.totalSize
},
/** Cumulative offset of row `index` from the top in px, regardless
* of whether `estimateSize` is uniform or per-index. Uses the
* cached offsets array under function-form sizing so the lookup
* is O(1) instead of O(index). */
getOffsetForIndex(index: number): number {
if (index <= 0) return 0
const count = Math.max(options.count, 0)
const bounded = Math.min(index, count)
if (typeof options.estimateSize === 'function') {
const offsets = getOffsets()
if (offsets) return offsets[bounded] ?? 0
let acc = 0
const fn = options.estimateSize
for (let i = 0; i < bounded; i += 1) acc += Math.max(fn(i), 1)
return acc
}
return bounded * Math.max(options.estimateSize, 1)
},
/** Height of row `index` in px (whichever estimateSize provides). */
getSizeForIndex(index: number): number {
if (index < 0 || index >= options.count) return 0
if (typeof options.estimateSize === 'function') {
return Math.max(options.estimateSize(index), 1)
}
return Math.max(options.estimateSize, 1)
},
getState() {
return state
},
subscribe(listener: VirtualizerListener) {
listeners.add(listener)
return () => listeners.delete(listener)
},
}
}