fix(ui): stabilize virtual message list measurements

This commit is contained in:
Shantur Rathore
2026-03-07 21:08:06 +00:00
parent 0d215342e3
commit c64a9a03f9
4 changed files with 327 additions and 39 deletions

View File

@@ -571,6 +571,7 @@ interface MessageBlockProps {
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
onFork?: (messageId?: string) => void
onContentRendered?: () => void
onMeasureElementChange?: (element: HTMLElement | null) => void
}
export default function MessageBlock(props: MessageBlockProps) {
@@ -578,7 +579,6 @@ export default function MessageBlock(props: MessageBlockProps) {
const record = createMemo(() => props.store().getMessage(props.messageId))
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
const sessionCache = getSessionRenderCache(props.instanceId, props.sessionId)
const isDeleteMessageHovered = () => {
const hover = props.deleteHover?.() ?? ({ kind: "none" } as DeleteHoverState)
@@ -787,10 +787,23 @@ export default function MessageBlock(props: MessageBlockProps) {
return resultBlock
})
onCleanup(() => {
props.onMeasureElementChange?.(null)
})
const visibleBlock = createMemo(() => {
const resolved = block()
if (!resolved || resolved.items.length === 0) return null
return resolved
})
return (
<Show when={block()}>
<Show when={visibleBlock()}>
{(resolvedBlock) => (
<div
ref={(el) => {
props.onMeasureElementChange?.(el)
}}
class="message-stream-block"
data-message-id={resolvedBlock().record.id}
data-delete-message-hover={isDeleteMessageHovered() ? "true" : undefined}

View File

@@ -1066,7 +1066,7 @@ export default function MessageSection(props: MessageSectionProps) {
</Show>
</>
)}
renderItem={(messageId, index) => (
renderItem={(messageId, index, options) => (
<MessageBlock
messageId={messageId}
instanceId={props.instanceId}
@@ -1085,7 +1085,11 @@ export default function MessageSection(props: MessageSectionProps) {
onRevert={props.onRevert}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
onFork={props.onFork}
onContentRendered={handleContentRendered}
onContentRendered={() => {
options.notifyItemRendered()
handleContentRendered()
}}
onMeasureElementChange={options.registerMeasureElement}
/>
)}
renderOverlay={() => (

View File

@@ -1,5 +1,5 @@
import { Index, Show, createEffect, createMemo, createSignal, onCleanup, type Accessor, type JSX } from "solid-js"
import VirtualItem from "./virtual-item"
import VirtualItem, { type VirtualItemHeightChangeMeta } from "./virtual-item"
const DEFAULT_SCROLL_SENTINEL_MARGIN_PX = 48
const USER_SCROLL_INTENT_WINDOW_MS = 600
@@ -30,7 +30,14 @@ export interface VirtualFollowListState {
export interface VirtualFollowListProps<T> {
items: Accessor<T[]>
getKey: (item: T, index: number) => string
renderItem: (item: T, index: number) => JSX.Element
renderItem: (
item: T,
index: number,
options: {
registerMeasureElement: (element: HTMLElement | null) => void
notifyItemRendered: () => void
},
) => JSX.Element
/**
* Optional stable DOM id for the item wrapper.
@@ -470,9 +477,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
const bottomAfter = rect.bottom
const bottomBefore = bottomAfter - delta
const wasAboveViewport = bottomBefore < containerRect.top
if (!wasAboveViewport) {
return
}
if (!wasAboveViewport) return
const next = (pendingScrollCompensations.get(key) ?? 0) + delta
pendingScrollCompensations.set(key, next)
@@ -883,16 +888,20 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
const anchorId = () => getAnchorId(key())
const overscanPx = props.overscanPx ?? 800
const suspendMeasurements = () => measurementsSuspended() || !isActive()
const [measureElement, setMeasureElement] = createSignal<HTMLElement | undefined>()
const [contentRenderVersion, setContentRenderVersion] = createSignal(0)
return (
<VirtualItem
id={anchorId()}
cacheKey={key()}
scrollContainer={scrollElement}
threshold={overscanPx}
measureElement={measureElement}
contentRenderVersion={contentRenderVersion}
placeholderClass="message-stream-placeholder"
virtualizationEnabled={virtualizationEnabled}
suspendMeasurements={suspendMeasurements}
onHeightChange={(nextHeight, previousHeight) => {
onHeightChange={(nextHeight, previousHeight, meta: VirtualItemHeightChangeMeta) => {
const delta = nextHeight - previousHeight
// Follow mode: keep the viewport pinned to the bottom as
@@ -913,11 +922,16 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
// while scrolling upward, compensate scrollTop so visible
// content stays stable.
if (delta) {
if (meta.isStaleCacheCorrection) return
scheduleScrollCompensation(key(), delta)
}
}}
>
{() => props.renderItem(item(), index)}
{() =>
props.renderItem(item(), index, {
registerMeasureElement: (element) => setMeasureElement(element ?? undefined),
notifyItemRendered: () => setContentRenderVersion((prev) => prev + 1),
})}
</VirtualItem>
)
}}

View File

@@ -4,6 +4,8 @@ const sizeCache = new Map<string, number>()
const DEFAULT_MARGIN_PX = 600
const MIN_PLACEHOLDER_HEIGHT = 400
const VISIBILITY_BUFFER_PX = 0
const SETTLED_HEIGHT_EPSILON_PX = 1
const SETTLED_HEIGHT_FRAMES = 2
type ObserverRoot = Element | Document | null
@@ -160,6 +162,8 @@ interface VirtualItemProps {
scrollContainer?: Accessor<HTMLElement | undefined | null>
threshold?: number
minPlaceholderHeight?: number
measureElement?: Accessor<HTMLElement | undefined | null>
contentRenderVersion?: Accessor<number>
class?: string
contentClass?: string
placeholderClass?: string
@@ -167,10 +171,17 @@ interface VirtualItemProps {
forceVisible?: Accessor<boolean>
suspendMeasurements?: Accessor<boolean>
onMeasured?: () => void
onHeightChange?: (nextHeight: number, previousHeight: number) => void
onHeightChange?: (nextHeight: number, previousHeight: number, meta: VirtualItemHeightChangeMeta) => void
id?: string
}
export interface VirtualItemHeightChangeMeta {
source: "initial-visible-measure" | "resize"
previousCachedHeight: number | null
isStaleCacheCorrection: boolean
wasHidden: boolean
}
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)
@@ -183,10 +194,12 @@ export default function VirtualItem(props: VirtualItemProps) {
// 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)
const [isSettlingVisible, setIsSettlingVisible] = createSignal(false)
let hasReportedMeasurement = Boolean(cachedHeight && cachedHeight > 0)
let pendingVisibility: boolean | null = null
let visibilityFrame: number | null = null
let awaitingVisibleMeasurement = true
let lastMeasurementWhileHidden = true
const flushVisibility = () => {
if (visibilityFrame !== null) {
cancelAnimationFrame(visibilityFrame)
@@ -198,6 +211,11 @@ export default function VirtualItem(props: VirtualItemProps) {
}
}
const queueVisibility = (nextValue: boolean) => {
if (nextValue && !isIntersecting()) {
setIsSettlingVisible(true)
} else if (!nextValue) {
setIsSettlingVisible(false)
}
pendingVisibility = nextValue
if (visibilityFrame !== null) return
visibilityFrame = requestAnimationFrame(() => {
@@ -215,13 +233,17 @@ export default function VirtualItem(props: VirtualItemProps) {
if (!virtualizationEnabled()) return false
return !isIntersecting()
})
let wrapperRef: HTMLDivElement | undefined
const shouldHideMountedContent = createMemo(() => shouldHideContent() || isSettlingVisible())
let wrapperRef: HTMLDivElement | undefined
let contentRef: HTMLDivElement | undefined
let resizeObserver: ResizeObserver | undefined
let intersectionCleanup: (() => void) | undefined
let delayedMeasureFrame: number | null = null
let delayedMeasureFrame2: number | null = null
let settlingMeasureFrame: number | null = null
let settlingStableFrames = 0
let settlingLastHeight: number | null = null
function cleanupResizeObserver() {
if (resizeObserver) {
@@ -230,6 +252,102 @@ export default function VirtualItem(props: VirtualItemProps) {
}
}
function clearDelayedMeasureFrames() {
if (delayedMeasureFrame !== null) {
cancelAnimationFrame(delayedMeasureFrame)
delayedMeasureFrame = null
}
if (delayedMeasureFrame2 !== null) {
cancelAnimationFrame(delayedMeasureFrame2)
delayedMeasureFrame2 = null
}
}
function clearSettlingMeasurementFrame() {
if (settlingMeasureFrame !== null) {
cancelAnimationFrame(settlingMeasureFrame)
settlingMeasureFrame = null
}
settlingStableFrames = 0
settlingLastHeight = null
}
function scheduleDelayedVisibleMeasurements() {
clearDelayedMeasureFrames()
delayedMeasureFrame = requestAnimationFrame(() => {
delayedMeasureFrame = null
if (shouldHideContent() || measurementsSuspended()) return
if (!contentRef) return
updateMeasuredHeight()
delayedMeasureFrame2 = requestAnimationFrame(() => {
delayedMeasureFrame2 = null
if (shouldHideContent() || measurementsSuspended()) return
if (!contentRef) return
updateMeasuredHeight()
})
})
}
function commitSettledMeasurement(next: number) {
const measurementSource: "initial-visible-measure" | "resize" = awaitingVisibleMeasurement ? "initial-visible-measure" : "resize"
const wasHidden = lastMeasurementWhileHidden
awaitingVisibleMeasurement = false
lastMeasurementWhileHidden = false
setIsSettlingVisible(false)
persistMeasurement(next, { source: measurementSource, wasHidden })
}
function scheduleSettledVisibleMeasurement() {
if (shouldHideContent() || measurementsSuspended()) return
if (!contentRef) return
setIsSettlingVisible(true)
clearSettlingMeasurementFrame()
const tick = () => {
settlingMeasureFrame = requestAnimationFrame(() => {
settlingMeasureFrame = null
if (shouldHideContent() || measurementsSuspended()) {
setIsSettlingVisible(false)
clearSettlingMeasurementFrame()
return
}
const measurement = getMeasuredContentRect()
if (!measurement) {
tick()
return
}
const sampledHeight = Math.max(0, Math.round(measurement.rect.height * 2) / 2)
const previousSample = settlingLastHeight
settlingLastHeight = sampledHeight
if (previousSample !== null && Math.abs(sampledHeight - previousSample) <= SETTLED_HEIGHT_EPSILON_PX) {
settlingStableFrames += 1
} else {
settlingStableFrames = 0
}
if (settlingStableFrames >= SETTLED_HEIGHT_FRAMES) {
clearSettlingMeasurementFrame()
commitSettledMeasurement(sampledHeight)
return
}
tick()
})
}
tick()
}
function scheduleContentRenderedMeasurements() {
if (shouldHideContent() || measurementsSuspended()) return
if (!contentRef) return
queueMicrotask(() => {
if (shouldHideContent() || measurementsSuspended()) return
if (!contentRef) return
setupResizeObserver()
scheduleSettledVisibleMeasurement()
})
scheduleDelayedVisibleMeasurements()
}
function cleanupIntersectionObserver() {
if (intersectionCleanup) {
intersectionCleanup()
@@ -237,13 +355,87 @@ export default function VirtualItem(props: VirtualItemProps) {
}
}
function persistMeasurement(nextHeight: number) {
function getMeasuredContentRect(): { rect: DOMRect } | null {
const explicitMeasureElement = props.measureElement?.()
if (explicitMeasureElement) {
return {
rect: explicitMeasureElement.getBoundingClientRect(),
}
}
if (!contentRef) return null
const childElements = Array.from(contentRef.children).filter((node): node is HTMLElement => node instanceof HTMLElement)
if (childElements.length === 0) {
return {
rect: contentRef.getBoundingClientRect(),
}
}
let top = Number.POSITIVE_INFINITY
let bottom = Number.NEGATIVE_INFINITY
let left = Number.POSITIVE_INFINITY
let right = Number.NEGATIVE_INFINITY
let sawNonZero = false
for (const child of childElements) {
const rect = child.getBoundingClientRect()
if (rect.width <= 0 && rect.height <= 0) continue
sawNonZero = true
if (rect.top < top) top = rect.top
if (rect.bottom > bottom) bottom = rect.bottom
if (rect.left < left) left = rect.left
if (rect.right > right) right = rect.right
}
if (!sawNonZero) {
return {
rect: contentRef.getBoundingClientRect(),
}
}
return {
rect: {
x: left,
y: top,
top,
bottom,
left,
right,
width: Math.max(0, right - left),
height: Math.max(0, bottom - top),
toJSON: () => ({
x: left,
y: top,
top,
bottom,
left,
right,
width: Math.max(0, right - left),
height: Math.max(0, bottom - top),
}),
} as DOMRect,
}
}
function persistMeasurement(nextHeight: number, meta?: { source: "initial-visible-measure" | "resize"; wasHidden: boolean }) {
if (!Number.isFinite(nextHeight) || nextHeight < 0) {
return
}
const before = measuredHeight()
const normalized = nextHeight
const previous = sizeCache.get(props.cacheKey) ?? measuredHeight()
const previousCachedHeight = sizeCache.get(props.cacheKey) ?? null
const previous = previousCachedHeight ?? measuredHeight()
const measurementMeta: VirtualItemHeightChangeMeta = {
source: meta?.source ?? "resize",
previousCachedHeight,
isStaleCacheCorrection:
(meta?.source ?? "resize") === "initial-visible-measure" &&
previousCachedHeight !== null &&
normalized > 0 &&
Math.abs(normalized - previousCachedHeight) > 1,
wasHidden: meta?.wasHidden ?? shouldHideContent(),
}
// 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
@@ -254,34 +446,45 @@ export default function VirtualItem(props: VirtualItemProps) {
hasReportedMeasurement = true
props.onMeasured?.()
}
setHasMeasured(true)
sizeCache.set(props.cacheKey, previous)
setMeasuredHeight(previous)
if (previous !== before) props.onHeightChange?.(previous, before)
if (previous !== before) props.onHeightChange?.(previous, before, measurementMeta)
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)
if (measurementMeta.isStaleCacheCorrection) {
requestAnimationFrame(() => {
recheckVisibilityAfterMeasurement()
})
}
if (normalized !== before) props.onHeightChange?.(normalized, before, measurementMeta)
}
function updateMeasuredHeight() {
if (!contentRef || measurementsSuspended()) return
if (!contentRef) return
if (measurementsSuspended()) return
// Prefer subpixel-accurate height for scroll compensation.
// offsetHeight rounds to integers which can accumulate error.
const rect = contentRef.getBoundingClientRect()
const measurement = getMeasuredContentRect()
if (!measurement) return
const { rect } = measurement
const next = Math.max(0, Math.round(rect.height * 2) / 2)
if (next === measuredHeight()) return
persistMeasurement(next)
const currentMeasured = measuredHeight()
if (next === currentMeasured) return
const measurementSource: "initial-visible-measure" | "resize" = awaitingVisibleMeasurement ? "initial-visible-measure" : "resize"
const wasHidden = lastMeasurementWhileHidden
awaitingVisibleMeasurement = false
lastMeasurementWhileHidden = false
persistMeasurement(next, { source: measurementSource, wasHidden })
}
function setupResizeObserver() {
if (!contentRef || measurementsSuspended()) return
cleanupResizeObserver()
@@ -291,6 +494,7 @@ export default function VirtualItem(props: VirtualItemProps) {
}
resizeObserver = new ResizeObserver(() => {
if (measurementsSuspended()) return
if (isSettlingVisible()) return
updateMeasuredHeight()
})
resizeObserver.observe(contentRef)
@@ -342,11 +546,17 @@ export default function VirtualItem(props: VirtualItemProps) {
}
try {
const rootRect = (targetRoot as Element).getBoundingClientRect()
const wrapperRect = wrapperEl.getBoundingClientRect()
const visible = shouldRenderByRects({
wrapperRect: wrapperEl.getBoundingClientRect(),
wrapperRect,
rootRect: { top: rootRect.top, bottom: rootRect.bottom },
margin,
})
const collapsedWhileVisible = isIntersecting() && !visible && Math.round(wrapperRect.height) <= 0 && measuredHeight() > 0
if (collapsedWhileVisible) {
queueVisibility(true)
return
}
queueVisibility(visible)
return
} catch {
@@ -359,6 +569,37 @@ export default function VirtualItem(props: VirtualItemProps) {
})
}
function recheckVisibilityAfterMeasurement() {
if (!wrapperRef) return
const margin = props.threshold ?? DEFAULT_MARGIN_PX
const wrapperRect = wrapperRef.getBoundingClientRect()
const targetRoot = props.scrollContainer ? props.scrollContainer() : null
if (targetRoot && !(targetRoot instanceof Document)) {
const rootRect = targetRoot.getBoundingClientRect()
const visible = shouldRenderByRects({
wrapperRect,
rootRect: { top: rootRect.top, bottom: rootRect.bottom },
margin,
})
if (!visible) {
queueVisibility(false)
}
return
}
const viewportRect = getViewportRect()
const visible = shouldRenderByRects({
wrapperRect,
rootRect: viewportRect,
margin,
})
if (!visible) {
queueVisibility(false)
}
}
function setWrapperRef(element: HTMLDivElement | null) {
wrapperRef = element ?? undefined
const root = props.scrollContainer ? props.scrollContainer() : null
@@ -377,30 +618,44 @@ export default function VirtualItem(props: VirtualItemProps) {
cleanupResizeObserver()
}
}
createEffect(() => {
if (shouldHideContent() || measurementsSuspended()) {
const hidden = shouldHideContent()
if (hidden) {
awaitingVisibleMeasurement = true
lastMeasurementWhileHidden = true
clearDelayedMeasureFrames()
clearSettlingMeasurementFrame()
setIsSettlingVisible(false)
}
if (hidden || measurementsSuspended()) {
cleanupResizeObserver()
} else if (contentRef) {
} else {
setIsSettlingVisible(true)
}
if (!hidden && !measurementsSuspended() && contentRef) {
queueMicrotask(() => {
updateMeasuredHeight()
setupResizeObserver()
scheduleSettledVisibleMeasurement()
})
scheduleDelayedVisibleMeasurements()
}
})
createEffect(() => {
const version = props.contentRenderVersion?.()
if (version === undefined) return
if (version <= 0) return
scheduleContentRenderedMeasurements()
})
createEffect(() => {
const key = props.cacheKey
const cached = sizeCache.get(key)
if (cached !== undefined) {
setMeasuredHeight(cached)
setHasMeasured(true)
} else {
setMeasuredHeight(fallbackPlaceholderHeight())
setHasMeasured(false)
}
})
@@ -418,17 +673,19 @@ export default function VirtualItem(props: VirtualItemProps) {
}
return props.minPlaceholderHeight ?? MIN_PLACEHOLDER_HEIGHT
})
onCleanup(() => {
cleanupResizeObserver()
cleanupIntersectionObserver()
clearDelayedMeasureFrames()
clearSettlingMeasurementFrame()
flushVisibility()
})
const wrapperClass = () => ["virtual-item-wrapper", props.class].filter(Boolean).join(" ")
const contentClass = () => {
const classes = ["virtual-item-content", props.contentClass]
if (shouldHideContent()) {
if (shouldHideMountedContent()) {
classes.push("virtual-item-content-hidden")
}
return classes.filter(Boolean).join(" ")
@@ -445,7 +702,7 @@ export default function VirtualItem(props: VirtualItemProps) {
class={placeholderClass()}
style={{
width: "100%",
height: shouldHideContent() ? `${placeholderHeight()}px` : undefined,
height: shouldHideContent() || isSettlingVisible() ? `${placeholderHeight()}px` : undefined,
}}
>
<div ref={setContentRef} class={contentClass()}>