import { JSX, Accessor, createEffect, createMemo, createSignal, onCleanup } from "solid-js" const sizeCache = new Map() const DEFAULT_MARGIN_PX = 600 const MIN_PLACEHOLDER_HEIGHT = 400 const VISIBILITY_BUFFER_PX = 0 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 } // Above the root: compare bottom edge to root top. if (entry.boundingClientRect.bottom < rootBounds.top) { const distance = rootBounds.top - entry.boundingClientRect.bottom return distance <= VISIBILITY_BUFFER_PX } // Below the root: compare top edge to root bottom. if (entry.boundingClientRect.top > rootBounds.bottom) { const distance = entry.boundingClientRect.top - rootBounds.bottom return distance <= VISIBILITY_BUFFER_PX } // Overlapping the root bounds. return true } function getViewportRect(): { top: number; bottom: number } { if (typeof window === "undefined") { return { top: 0, bottom: 0 } } return { top: 0, bottom: window.innerHeight } } function isRenderableRoot(root: ObserverRoot): boolean { if (!root) return true if (root instanceof Document) return true if (typeof window === "undefined") return false const element = root as Element const style = window.getComputedStyle(element as Element) if (style.display === "none" || style.visibility === "hidden") { return false } const rect = (element as Element).getBoundingClientRect() return rect.width > 0 && rect.height > 0 } function shouldRenderByRects(params: { wrapperRect: DOMRect rootRect: { top: number; bottom: number } margin: number }): boolean { const { wrapperRect, rootRect, margin } = params const threshold = margin + VISIBILITY_BUFFER_PX // Above the root: compare bottom edge to root top. if (wrapperRect.bottom < rootRect.top) { const distance = rootRect.top - wrapperRect.bottom return distance <= threshold } // Below the root: compare top edge to root bottom. if (wrapperRect.top > rootRect.bottom) { const distance = wrapperRect.top - rootRect.bottom return distance <= threshold } 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 | (() => JSX.Element) scrollContainer?: Accessor threshold?: number minPlaceholderHeight?: number class?: string contentClass?: string placeholderClass?: string virtualizationEnabled?: Accessor forceVisible?: Accessor suspendMeasurements?: Accessor onMeasured?: () => void onHeightChange?: (nextHeight: number, previousHeight: number) => void id?: string } export default function VirtualItem(props: VirtualItemProps) { const resolveContent = () => (typeof props.children === "function" ? (props.children as () => JSX.Element)() : props.children) const cachedHeight = sizeCache.get(props.cacheKey) const fallbackPlaceholderHeight = () => props.minPlaceholderHeight ?? MIN_PLACEHOLDER_HEIGHT // Default to hidden until we can determine visibility. // This avoids keeping heavy DOM alive when IntersectionObserver // doesn't fire (common for hidden/zero-sized scroll roots). const [isIntersecting, setIsIntersecting] = createSignal(false) // Keep measuredHeight aligned with the *effective layout height* while hidden. // When content first mounts, onHeightChange deltas should reflect the DOM's // placeholder height (not 0), otherwise scroll compensation can overshoot. const [measuredHeight, setMeasuredHeight] = createSignal(cachedHeight ?? fallbackPlaceholderHeight()) 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 measurementsSuspended = () => Boolean(props.suspendMeasurements?.()) const shouldHideContent = createMemo(() => { if (props.forceVisible?.()) return false if (!virtualizationEnabled()) return false return !isIntersecting() }) 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 before = measuredHeight() const normalized = nextHeight const previous = sizeCache.get(props.cacheKey) ?? measuredHeight() // Only keep the previous measurement when the element reports 0 height. // Allow shrinkage so placeholder height matches real content height; // keeping the max height can cause mount/unmount jitter near the // virtualization boundary. const shouldKeepPrevious = previous > 0 && normalized === 0 if (shouldKeepPrevious) { if (!hasReportedMeasurement) { hasReportedMeasurement = true props.onMeasured?.() } setHasMeasured(true) sizeCache.set(props.cacheKey, previous) setMeasuredHeight(previous) if (previous !== before) props.onHeightChange?.(previous, before) return } if (normalized > 0) { sizeCache.set(props.cacheKey, normalized) setHasMeasured(true) if (!hasReportedMeasurement) { hasReportedMeasurement = true props.onMeasured?.() } } setMeasuredHeight(normalized) if (normalized !== before) props.onHeightChange?.(normalized, before) } function updateMeasuredHeight() { if (!contentRef || measurementsSuspended()) return // Prefer subpixel-accurate height for scroll compensation. // offsetHeight rounds to integers which can accumulate error. const rect = contentRef.getBoundingClientRect() const next = Math.max(0, Math.round(rect.height * 2) / 2) 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(false) return } if (typeof IntersectionObserver === "undefined") { setIsIntersecting(true) return } const margin = props.threshold ?? DEFAULT_MARGIN_PX // If the scroll root is hidden / 0x0, IntersectionObserver can report // `isIntersecting` in unexpected ways (often "true" with null rootBounds), // which keeps heavy DOM alive in background tabs. // // In that state, force-hide and skip attaching the observer. When the // pane becomes visible again, VirtualItem will re-run this setup and // re-attach the observer. const renderable = isRenderableRoot(targetRoot) if (!renderable) { setIsIntersecting(false) return } // Avoid doing an eager geometry read here. // During large list hydration / initial layout, wrapper rects can be // transiently 0/incorrect and cause many offscreen items to mount. // Rely on the observer callback (which we harden below) to determine // visibility. const wrapperEl = wrapperRef intersectionCleanup = subscribeToSharedObserver(wrapperEl, targetRoot, margin, (entry) => { // IntersectionObserver can produce transient false-positives during pane // activation/layout transitions (e.g. `isIntersecting: true` for items far // outside the scroll root). For element roots, prefer explicit rect math. if (targetRoot && !(targetRoot instanceof Document)) { // When rootBounds is null we cannot trust the entry; treat as hidden. if (entry.rootBounds === null) { queueVisibility(false) return } try { const rootRect = (targetRoot as Element).getBoundingClientRect() const visible = shouldRenderByRects({ wrapperRect: wrapperEl.getBoundingClientRect(), rootRect: { top: rootRect.top, bottom: rootRect.bottom }, margin, }) queueVisibility(visible) return } catch { // Fall through to the entry-based heuristic. } } 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(fallbackPlaceholderHeight()) 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 resolveContent() }) return (
{lazyContent()}
) }