fix(ui): avoid offscreen mounts during initial layout

This commit is contained in:
Shantur Rathore
2026-03-03 10:52:59 +00:00
parent 044e46cd6b
commit 95df743339
2 changed files with 10 additions and 19 deletions

View File

@@ -761,7 +761,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
} }
}} }}
> >
{props.renderItem(item(), index)} {() => props.renderItem(item(), index)}
</VirtualItem> </VirtualItem>
) )
}} }}

View File

@@ -1,4 +1,4 @@
import { JSX, Accessor, children as resolveChildren, createEffect, createMemo, createSignal, onCleanup } from "solid-js" import { JSX, Accessor, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
const sizeCache = new Map<string, number>() const sizeCache = new Map<string, number>()
const DEFAULT_MARGIN_PX = 600 const DEFAULT_MARGIN_PX = 600
@@ -156,7 +156,7 @@ function subscribeToSharedObserver(
interface VirtualItemProps { interface VirtualItemProps {
cacheKey: string cacheKey: string
children: JSX.Element children: JSX.Element | (() => JSX.Element)
scrollContainer?: Accessor<HTMLElement | undefined | null> scrollContainer?: Accessor<HTMLElement | undefined | null>
threshold?: number threshold?: number
minPlaceholderHeight?: number minPlaceholderHeight?: number
@@ -172,7 +172,7 @@ interface VirtualItemProps {
} }
export default function VirtualItem(props: VirtualItemProps) { export default function VirtualItem(props: VirtualItemProps) {
const resolved = resolveChildren(() => props.children) const resolveContent = () => (typeof props.children === "function" ? (props.children as () => JSX.Element)() : props.children)
const cachedHeight = sizeCache.get(props.cacheKey) const cachedHeight = sizeCache.get(props.cacheKey)
// Default to hidden until we can determine visibility. // Default to hidden until we can determine visibility.
// This avoids keeping heavy DOM alive when IntersectionObserver // This avoids keeping heavy DOM alive when IntersectionObserver
@@ -319,20 +319,11 @@ export default function VirtualItem(props: VirtualItemProps) {
return return
} }
// Compute an immediate best-effort visibility so switching tabs doesn't // Avoid doing an eager geometry read here.
// depend on the first IntersectionObserver callback. // During large list hydration / initial layout, wrapper rects can be
try { // transiently 0/incorrect and cause many offscreen items to mount.
const rootRect = // Rely on the observer callback (which we harden below) to determine
targetRoot && !(targetRoot instanceof Document) // visibility.
? (targetRoot as Element).getBoundingClientRect()
: null
const bounds = rootRect ? { top: rootRect.top, bottom: rootRect.bottom } : getViewportRect()
setIsIntersecting(
shouldRenderByRects({ wrapperRect: wrapperRef.getBoundingClientRect(), rootRect: bounds, margin }),
)
} catch {
// Ignore measurement failures; IntersectionObserver will correct us.
}
const wrapperEl = wrapperRef const wrapperEl = wrapperRef
intersectionCleanup = subscribeToSharedObserver(wrapperEl, targetRoot, margin, (entry) => { intersectionCleanup = subscribeToSharedObserver(wrapperEl, targetRoot, margin, (entry) => {
@@ -441,7 +432,7 @@ export default function VirtualItem(props: VirtualItemProps) {
const placeholderClass = () => ["virtual-item-placeholder", props.placeholderClass].filter(Boolean).join(" ") const placeholderClass = () => ["virtual-item-placeholder", props.placeholderClass].filter(Boolean).join(" ")
const lazyContent = createMemo<JSX.Element | null>(() => { const lazyContent = createMemo<JSX.Element | null>(() => {
if (shouldHideContent()) return null if (shouldHideContent()) return null
return resolved() return resolveContent()
}) })
return ( return (