lazy mount message blocks

This commit is contained in:
Shantur Rathore
2025-12-09 01:04:49 +00:00
parent 7aba3c1221
commit 7996228327
2 changed files with 33 additions and 55 deletions

View File

@@ -8,11 +8,9 @@ export function getMessageAnchorId(messageId: string) {
}
const VIRTUAL_ITEM_MARGIN_PX = 800
const ESTIMATED_MESSAGE_HEIGHT = 320
const INITIAL_FORCE_MIN_ITEMS = 12
const INITIAL_FORCE_OVERSCAN = 6
interface MessageBlockListProps {
instanceId: string
sessionId: string
store: () => InstanceMessageStore
@@ -31,50 +29,10 @@ interface MessageBlockListProps {
}
export default function MessageBlockList(props: MessageBlockListProps) {
const [initialForceActive, setInitialForceActive] = createSignal(true)
const [initialForceInitialized, setInitialForceInitialized] = createSignal(false)
const [initialForceStartIndex, setInitialForceStartIndex] = createSignal(0)
const [, setInitialForceRemaining] = createSignal(0)
createEffect(() => {
props.instanceId
props.sessionId
setInitialForceActive(true)
setInitialForceInitialized(false)
setInitialForceStartIndex(0)
setInitialForceRemaining(0)
})
createEffect(() => {
if (!initialForceActive() || initialForceInitialized()) return
const ids = props.messageIds()
if (ids.length === 0) return
const viewportHeight = props.scrollContainer()?.clientHeight ?? (typeof window !== "undefined" ? window.innerHeight : 800)
const estimatedCount = Math.min(
ids.length,
Math.max(INITIAL_FORCE_MIN_ITEMS, Math.ceil(viewportHeight / ESTIMATED_MESSAGE_HEIGHT) + INITIAL_FORCE_OVERSCAN),
)
setInitialForceStartIndex(Math.max(0, ids.length - estimatedCount))
setInitialForceRemaining(estimatedCount)
setInitialForceInitialized(true)
})
return (
<>
<Index each={props.messageIds()}>
{(messageId) => {
const messageIndex = () => props.messageIndexMap().get(messageId()) ?? 0
const forceVisible = () => initialForceActive() && messageIndex() >= initialForceStartIndex()
const handleMeasured = () => {
if (!forceVisible()) return
setInitialForceRemaining((value) => {
const next = value > 0 ? value - 1 : 0
if (next === 0) {
setInitialForceActive(false)
}
return next
})
}
return (
<VirtualItem
id={getMessageAnchorId(messageId())}
@@ -83,8 +41,6 @@ export default function MessageBlockList(props: MessageBlockListProps) {
threshold={VIRTUAL_ITEM_MARGIN_PX}
placeholderClass="message-stream-placeholder"
virtualizationEnabled={() => !props.loading}
forceVisible={forceVisible}
onMeasured={handleMeasured}
>
<MessageBlock

View File

@@ -1,4 +1,4 @@
import { JSX, Show, Accessor, children as resolveChildren, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { JSX, Accessor, children as resolveChildren, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
const sizeCache = new Map<string, number>()
const DEFAULT_MARGIN_PX = 600
@@ -133,8 +133,14 @@ export default function VirtualItem(props: VirtualItemProps) {
})
}
const virtualizationEnabled = () => (props.virtualizationEnabled ? props.virtualizationEnabled() : true)
const shouldHideContent = createMemo(() => {
if (props.forceVisible?.()) return false
if (!virtualizationEnabled()) return false
return !isIntersecting()
})
let wrapperRef: HTMLDivElement | undefined
let wrapperRef: HTMLDivElement | undefined
let contentRef: HTMLDivElement | undefined
let resizeObserver: ResizeObserver | undefined
let intersectionCleanup: (() => void) | undefined
@@ -213,6 +219,7 @@ export default function VirtualItem(props: VirtualItemProps) {
contentRef = element ?? undefined
if (contentRef) {
queueMicrotask(() => {
if (shouldHideContent()) return
updateMeasuredHeight()
setupResizeObserver()
})
@@ -220,9 +227,21 @@ export default function VirtualItem(props: VirtualItemProps) {
cleanupResizeObserver()
}
}
createEffect(() => {
if (shouldHideContent()) {
cleanupResizeObserver()
} else if (contentRef) {
queueMicrotask(() => {
updateMeasuredHeight()
setupResizeObserver()
})
}
})
createEffect(() => {
const key = props.cacheKey
const cached = sizeCache.get(key)
if (cached !== undefined) {
setMeasuredHeight(cached)
@@ -238,13 +257,8 @@ export default function VirtualItem(props: VirtualItemProps) {
refreshIntersectionObserver(root ?? null)
})
const shouldHideContent = createMemo(() => {
if (props.forceVisible?.()) return false
if (!virtualizationEnabled()) return false
return !isIntersecting()
})
const placeholderHeight = createMemo(() => {
const seenHeight = measuredHeight()
if (seenHeight > 0) {
return seenHeight
@@ -267,6 +281,14 @@ export default function VirtualItem(props: VirtualItemProps) {
return classes.filter(Boolean).join(" ")
}
const placeholderClass = () => ["virtual-item-placeholder", props.placeholderClass].filter(Boolean).join(" ")
const lazyContent = createMemo<JSX.Element | null>(() => {
if (shouldHideContent()) return null
if (import.meta.env?.DEV) {
console.debug("rendering virtual item", props.cacheKey)
}
return resolved()
})
return (
<div ref={setWrapperRef} id={props.id} class={wrapperClass()} style={{ width: "100%" }}>
@@ -278,7 +300,7 @@ export default function VirtualItem(props: VirtualItemProps) {
}}
>
<div ref={setContentRef} class={contentClass()}>
{resolved()}
{lazyContent()}
</div>
</div>
</div>