fix(ui): stabilize virtual list rerender measurements

Keep visible rows mounted during follow-up measurements and clear stale refs so async message rendering no longer flickers or measures detached blocks. Coalesce per-item render notifications so content-heavy rows only trigger one remeasurement per frame.
This commit is contained in:
Shantur Rathore
2026-03-08 18:09:43 +00:00
parent c64a9a03f9
commit b33421a375
3 changed files with 49 additions and 18 deletions

View File

@@ -787,7 +787,10 @@ export default function MessageBlock(props: MessageBlockProps) {
return resultBlock
})
let measuredBlockElement: HTMLDivElement | undefined
onCleanup(() => {
measuredBlockElement = undefined
props.onMeasureElementChange?.(null)
})
@@ -797,11 +800,19 @@ export default function MessageBlock(props: MessageBlockProps) {
return resolved
})
createEffect(() => {
if (visibleBlock()) return
if (!measuredBlockElement) return
measuredBlockElement = undefined
props.onMeasureElementChange?.(null)
})
return (
<Show when={visibleBlock()}>
{(resolvedBlock) => (
<div
ref={(el) => {
measuredBlockElement = el
props.onMeasureElementChange?.(el)
}}
class="message-stream-block"

View File

@@ -890,6 +890,23 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
const suspendMeasurements = () => measurementsSuspended() || !isActive()
const [measureElement, setMeasureElement] = createSignal<HTMLElement | undefined>()
const [contentRenderVersion, setContentRenderVersion] = createSignal(0)
let pendingContentRenderFrame: number | null = null
const notifyItemRendered = () => {
if (pendingContentRenderFrame !== null) return
pendingContentRenderFrame = requestAnimationFrame(() => {
pendingContentRenderFrame = null
setContentRenderVersion((prev) => prev + 1)
})
}
onCleanup(() => {
if (pendingContentRenderFrame !== null) {
cancelAnimationFrame(pendingContentRenderFrame)
pendingContentRenderFrame = null
}
})
return (
<VirtualItem
id={anchorId()}
@@ -930,7 +947,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
{() =>
props.renderItem(item(), index, {
registerMeasureElement: (element) => setMeasureElement(element ?? undefined),
notifyItemRendered: () => setContentRenderVersion((prev) => prev + 1),
notifyItemRendered,
})}
</VirtualItem>
)

View File

@@ -182,6 +182,8 @@ export interface VirtualItemHeightChangeMeta {
wasHidden: boolean
}
type VisibleSettlingMode = "hidden-to-visible" | "visible-rerender"
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)
@@ -194,7 +196,8 @@ 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 [isSettlingVisible, setIsSettlingVisible] = createSignal(false)
const [settlingMode, setSettlingMode] = createSignal<VisibleSettlingMode | null>(null)
const isSettlingVisible = createMemo(() => settlingMode() !== null)
let hasReportedMeasurement = Boolean(cachedHeight && cachedHeight > 0)
let pendingVisibility: boolean | null = null
let visibilityFrame: number | null = null
@@ -212,9 +215,9 @@ export default function VirtualItem(props: VirtualItemProps) {
}
const queueVisibility = (nextValue: boolean) => {
if (nextValue && !isIntersecting()) {
setIsSettlingVisible(true)
setSettlingMode("hidden-to-visible")
} else if (!nextValue) {
setIsSettlingVisible(false)
setSettlingMode(null)
}
pendingVisibility = nextValue
if (visibilityFrame !== null) return
@@ -233,7 +236,7 @@ export default function VirtualItem(props: VirtualItemProps) {
if (!virtualizationEnabled()) return false
return !isIntersecting()
})
const shouldHideMountedContent = createMemo(() => shouldHideContent() || isSettlingVisible())
const shouldHideMountedContent = createMemo(() => shouldHideContent() || settlingMode() === "hidden-to-visible")
let wrapperRef: HTMLDivElement | undefined
let contentRef: HTMLDivElement | undefined
@@ -272,6 +275,10 @@ export default function VirtualItem(props: VirtualItemProps) {
settlingLastHeight = null
}
function getVisibleSettlingMode(): VisibleSettlingMode {
return awaitingVisibleMeasurement ? "hidden-to-visible" : "visible-rerender"
}
function scheduleDelayedVisibleMeasurements() {
clearDelayedMeasureFrames()
delayedMeasureFrame = requestAnimationFrame(() => {
@@ -293,21 +300,21 @@ export default function VirtualItem(props: VirtualItemProps) {
const wasHidden = lastMeasurementWhileHidden
awaitingVisibleMeasurement = false
lastMeasurementWhileHidden = false
setIsSettlingVisible(false)
setSettlingMode(null)
persistMeasurement(next, { source: measurementSource, wasHidden })
}
function scheduleSettledVisibleMeasurement() {
function scheduleSettledVisibleMeasurement(mode = getVisibleSettlingMode()) {
if (shouldHideContent() || measurementsSuspended()) return
if (!contentRef) return
setIsSettlingVisible(true)
setSettlingMode(mode)
clearSettlingMeasurementFrame()
const tick = () => {
settlingMeasureFrame = requestAnimationFrame(() => {
settlingMeasureFrame = null
if (shouldHideContent() || measurementsSuspended()) {
setIsSettlingVisible(false)
setSettlingMode(null)
clearSettlingMeasurementFrame()
return
}
@@ -343,7 +350,7 @@ export default function VirtualItem(props: VirtualItemProps) {
if (shouldHideContent() || measurementsSuspended()) return
if (!contentRef) return
setupResizeObserver()
scheduleSettledVisibleMeasurement()
scheduleSettledVisibleMeasurement(getVisibleSettlingMode())
})
scheduleDelayedVisibleMeasurements()
}
@@ -357,7 +364,7 @@ export default function VirtualItem(props: VirtualItemProps) {
function getMeasuredContentRect(): { rect: DOMRect } | null {
const explicitMeasureElement = props.measureElement?.()
if (explicitMeasureElement) {
if (explicitMeasureElement?.isConnected) {
return {
rect: explicitMeasureElement.getBoundingClientRect(),
}
@@ -480,8 +487,6 @@ export default function VirtualItem(props: VirtualItemProps) {
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 })
}
@@ -625,17 +630,15 @@ export default function VirtualItem(props: VirtualItemProps) {
lastMeasurementWhileHidden = true
clearDelayedMeasureFrames()
clearSettlingMeasurementFrame()
setIsSettlingVisible(false)
setSettlingMode(null)
}
if (hidden || measurementsSuspended()) {
cleanupResizeObserver()
} else {
setIsSettlingVisible(true)
}
if (!hidden && !measurementsSuspended() && contentRef) {
queueMicrotask(() => {
setupResizeObserver()
scheduleSettledVisibleMeasurement()
scheduleSettledVisibleMeasurement(getVisibleSettlingMode())
})
scheduleDelayedVisibleMeasurements()
}
@@ -702,7 +705,7 @@ export default function VirtualItem(props: VirtualItemProps) {
class={placeholderClass()}
style={{
width: "100%",
height: shouldHideContent() || isSettlingVisible() ? `${placeholderHeight()}px` : undefined,
height: shouldHideMountedContent() ? `${placeholderHeight()}px` : undefined,
}}
>
<div ref={setContentRef} class={contentClass()}>