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

View File

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

View File

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