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:
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()}>
|
||||||
|
|||||||
Reference in New Issue
Block a user