import { JSX, Accessor, children as resolveChildren, createEffect, createMemo, createSignal, onCleanup } from "solid-js" const sizeCache = new Map() const DEFAULT_MARGIN_PX = 600 const MIN_PLACEHOLDER_HEIGHT = 32 const VISIBILITY_BUFFER_PX = 48 type ObserverRoot = Element | Document | null type IntersectionCallback = (entry: IntersectionObserverEntry) => void interface SharedObserver { observer: IntersectionObserver listeners: Map> } const NULL_ROOT_KEY = "__null__" const rootIds = new WeakMap() let sharedRootId = 0 const sharedObservers = new Map() function getRootKey(root: ObserverRoot, margin: number): string { if (!root) { return `${NULL_ROOT_KEY}:${margin}` } let id = rootIds.get(root) if (id === undefined) { id = ++sharedRootId rootIds.set(root, id) } return `${id}:${margin}` } function createSharedObserver(root: ObserverRoot, margin: number): SharedObserver { const listeners = new Map>() const observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { const callbacks = listeners.get(entry.target as Element) if (!callbacks) return callbacks.forEach((fn) => fn(entry)) }) }, { root: root ?? undefined, rootMargin: `${margin}px 0px ${margin}px 0px`, }, ) return { observer, listeners } } function shouldRenderEntry(entry: IntersectionObserverEntry) { const rootBounds = entry.rootBounds if (!rootBounds) { return entry.isIntersecting } const distanceAbove = rootBounds.top - entry.boundingClientRect.bottom const distanceBelow = entry.boundingClientRect.top - rootBounds.bottom if (distanceAbove > VISIBILITY_BUFFER_PX || distanceBelow > VISIBILITY_BUFFER_PX) { return false } return true } function subscribeToSharedObserver( target: Element, root: ObserverRoot, margin: number, callback: IntersectionCallback, ): () => void { if (typeof IntersectionObserver === "undefined") { callback({ isIntersecting: true } as IntersectionObserverEntry) return () => {} } const key = getRootKey(root, margin) let shared = sharedObservers.get(key) if (!shared) { shared = createSharedObserver(root, margin) sharedObservers.set(key, shared) } let targetCallbacks = shared.listeners.get(target) if (!targetCallbacks) { targetCallbacks = new Set() shared.listeners.set(target, targetCallbacks) shared.observer.observe(target) } targetCallbacks.add(callback) return () => { const current = shared?.listeners.get(target) if (current) { current.delete(callback) if (current.size === 0) { shared?.listeners.delete(target) shared?.observer.unobserve(target) } } if (shared && shared.listeners.size === 0) { shared.observer.disconnect() sharedObservers.delete(key) } } } interface VirtualItemProps { cacheKey: string children: JSX.Element scrollContainer?: Accessor threshold?: number minPlaceholderHeight?: number class?: string contentClass?: string placeholderClass?: string virtualizationEnabled?: Accessor forceVisible?: Accessor suspendMeasurements?: Accessor onMeasured?: () => void id?: string } export default function VirtualItem(props: VirtualItemProps) { const resolved = resolveChildren(() => props.children) const cachedHeight = sizeCache.get(props.cacheKey) const [isIntersecting, setIsIntersecting] = createSignal(true) const [measuredHeight, setMeasuredHeight] = createSignal(cachedHeight ?? 0) const [hasMeasured, setHasMeasured] = createSignal(cachedHeight !== undefined) let hasReportedMeasurement = Boolean(cachedHeight && cachedHeight > 0) let pendingVisibility: boolean | null = null let visibilityFrame: number | null = null const flushVisibility = () => { if (visibilityFrame !== null) { cancelAnimationFrame(visibilityFrame) visibilityFrame = null } if (pendingVisibility !== null) { setIsIntersecting(pendingVisibility) pendingVisibility = null } } const queueVisibility = (nextValue: boolean) => { pendingVisibility = nextValue if (visibilityFrame !== null) return visibilityFrame = requestAnimationFrame(() => { visibilityFrame = null if (pendingVisibility !== null) { setIsIntersecting(pendingVisibility) pendingVisibility = null } }) } const virtualizationEnabled = () => (props.virtualizationEnabled ? props.virtualizationEnabled() : true) const shouldHideContent = createMemo(() => { if (props.forceVisible?.()) return false if (!virtualizationEnabled()) return false return !isIntersecting() }) const measurementsSuspended = () => Boolean(props.suspendMeasurements?.()) let wrapperRef: HTMLDivElement | undefined let contentRef: HTMLDivElement | undefined let resizeObserver: ResizeObserver | undefined let intersectionCleanup: (() => void) | undefined function cleanupResizeObserver() { if (resizeObserver) { resizeObserver.disconnect() resizeObserver = undefined } } function cleanupIntersectionObserver() { if (intersectionCleanup) { intersectionCleanup() intersectionCleanup = undefined } } function persistMeasurement(nextHeight: number) { if (!Number.isFinite(nextHeight) || nextHeight < 0) { return } const normalized = nextHeight const previous = sizeCache.get(props.cacheKey) ?? measuredHeight() const shouldKeepPrevious = previous > 0 && (normalized === 0 || (normalized > 0 && normalized < previous)) if (shouldKeepPrevious) { if (!hasReportedMeasurement) { hasReportedMeasurement = true props.onMeasured?.() } setHasMeasured(true) sizeCache.set(props.cacheKey, previous) setMeasuredHeight(previous) return } if (normalized > 0) { sizeCache.set(props.cacheKey, normalized) setHasMeasured(true) if (!hasReportedMeasurement) { hasReportedMeasurement = true props.onMeasured?.() } } setMeasuredHeight(normalized) } function updateMeasuredHeight() { if (!contentRef || measurementsSuspended()) return const next = contentRef.offsetHeight if (next === measuredHeight()) return persistMeasurement(next) } function setupResizeObserver() { if (!contentRef || measurementsSuspended()) return cleanupResizeObserver() if (typeof ResizeObserver === "undefined") { updateMeasuredHeight() return } resizeObserver = new ResizeObserver(() => { if (measurementsSuspended()) return updateMeasuredHeight() }) resizeObserver.observe(contentRef) } function refreshIntersectionObserver(targetRoot: Element | Document | null) { cleanupIntersectionObserver() if (!wrapperRef) { setIsIntersecting(true) return } if (typeof IntersectionObserver === "undefined") { setIsIntersecting(true) return } const margin = props.threshold ?? DEFAULT_MARGIN_PX intersectionCleanup = subscribeToSharedObserver(wrapperRef, targetRoot, margin, (entry) => { const nextVisible = shouldRenderEntry(entry) queueVisibility(nextVisible) }) } function setWrapperRef(element: HTMLDivElement | null) { wrapperRef = element ?? undefined const root = props.scrollContainer ? props.scrollContainer() : null refreshIntersectionObserver(root ?? null) } function setContentRef(element: HTMLDivElement | null) { contentRef = element ?? undefined if (contentRef) { queueMicrotask(() => { if (shouldHideContent() || measurementsSuspended()) return updateMeasuredHeight() setupResizeObserver() }) } else { cleanupResizeObserver() } } createEffect(() => { if (shouldHideContent() || measurementsSuspended()) { cleanupResizeObserver() } else if (contentRef) { queueMicrotask(() => { updateMeasuredHeight() setupResizeObserver() }) } }) createEffect(() => { const key = props.cacheKey const cached = sizeCache.get(key) if (cached !== undefined) { setMeasuredHeight(cached) setHasMeasured(true) } else { setMeasuredHeight(0) setHasMeasured(false) } }) createEffect(() => { measurementsSuspended() const root = props.scrollContainer ? props.scrollContainer() : null refreshIntersectionObserver(root ?? null) }) const placeholderHeight = createMemo(() => { const seenHeight = measuredHeight() if (seenHeight > 0) { return seenHeight } return props.minPlaceholderHeight ?? MIN_PLACEHOLDER_HEIGHT }) onCleanup(() => { cleanupResizeObserver() cleanupIntersectionObserver() flushVisibility() }) const wrapperClass = () => ["virtual-item-wrapper", props.class].filter(Boolean).join(" ") const contentClass = () => { const classes = ["virtual-item-content", props.contentClass] if (shouldHideContent()) { classes.push("virtual-item-content-hidden") } return classes.filter(Boolean).join(" ") } const placeholderClass = () => ["virtual-item-placeholder", props.placeholderClass].filter(Boolean).join(" ") const lazyContent = createMemo(() => { if (shouldHideContent()) return null return resolved() }) return (
{lazyContent()}
) }