fix(ui): stabilize streaming follow mode

Disable follow-mode virtualization churn and simplify reasoning header layout so streaming thinking blocks stop nudging the scroll position while the list is pinned to bottom.
This commit is contained in:
Shantur Rathore
2026-03-10 18:44:55 +00:00
parent b33421a375
commit f77fb1562e
4 changed files with 79 additions and 391 deletions

View File

@@ -571,7 +571,6 @@ interface MessageBlockProps {
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void> onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
onFork?: (messageId?: string) => void onFork?: (messageId?: string) => void
onContentRendered?: () => void onContentRendered?: () => void
onMeasureElementChange?: (element: HTMLElement | null) => void
} }
export default function MessageBlock(props: MessageBlockProps) { export default function MessageBlock(props: MessageBlockProps) {
@@ -787,34 +786,10 @@ export default function MessageBlock(props: MessageBlockProps) {
return resultBlock return resultBlock
}) })
let measuredBlockElement: HTMLDivElement | undefined
onCleanup(() => {
measuredBlockElement = undefined
props.onMeasureElementChange?.(null)
})
const visibleBlock = createMemo(() => {
const resolved = block()
if (!resolved || resolved.items.length === 0) return null
return resolved
})
createEffect(() => {
if (visibleBlock()) return
if (!measuredBlockElement) return
measuredBlockElement = undefined
props.onMeasureElementChange?.(null)
})
return ( return (
<Show when={visibleBlock()}> <Show when={block()}>
{(resolvedBlock) => ( {(resolvedBlock) => (
<div <div
ref={(el) => {
measuredBlockElement = el
props.onMeasureElementChange?.(el)
}}
class="message-stream-block" class="message-stream-block"
data-message-id={resolvedBlock().record.id} data-message-id={resolvedBlock().record.id}
data-delete-message-hover={isDeleteMessageHovered() ? "true" : undefined} data-delete-message-hover={isDeleteMessageHovered() ? "true" : undefined}
@@ -1314,12 +1289,6 @@ function ReasoningCard(props: ReasoningCardProps) {
const [deletingUpTo, setDeletingUpTo] = createSignal(false) const [deletingUpTo, setDeletingUpTo] = createSignal(false)
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId)) const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
let headerEl: HTMLDivElement | undefined
let actionsEl: HTMLDivElement | undefined
let primaryEl: HTMLSpanElement | undefined
let metaMeasureEl: HTMLSpanElement | undefined
const [showMetaInline, setShowMetaInline] = createSignal(true)
createEffect(() => { createEffect(() => {
setExpanded(Boolean(props.defaultExpanded)) setExpanded(Boolean(props.defaultExpanded))
}) })
@@ -1347,33 +1316,6 @@ function ReasoningCard(props: ReasoningCardProps) {
const hasMeta = () => Boolean(props.showAgentMeta && (agentIdentifier() || modelIdentifier())) const hasMeta = () => Boolean(props.showAgentMeta && (agentIdentifier() || modelIdentifier()))
const updateMetaLayout = () => {
if (!hasMeta()) return
if (!headerEl || !actionsEl || !primaryEl || !metaMeasureEl) return
const headerWidth = headerEl.getBoundingClientRect().width
const actionsWidth = actionsEl.getBoundingClientRect().width
const primaryWidth = primaryEl.getBoundingClientRect().width
const metaWidth = metaMeasureEl.getBoundingClientRect().width
const availableLeft = Math.max(0, headerWidth - actionsWidth - 12)
setShowMetaInline(primaryWidth + metaWidth + 8 <= availableLeft)
}
createEffect(() => {
if (!hasMeta() || typeof ResizeObserver === "undefined") {
setShowMetaInline(true)
return
}
updateMetaLayout()
const observer = new ResizeObserver(() => updateMetaLayout())
if (headerEl) observer.observe(headerEl)
if (actionsEl) observer.observe(actionsEl)
if (primaryEl) observer.observe(primaryEl)
onCleanup(() => observer.disconnect())
})
const reasoningText = () => { const reasoningText = () => {
const part = props.part as any const part = props.part as any
if (!part) return "" if (!part) return ""
@@ -1452,7 +1394,7 @@ function ReasoningCard(props: ReasoningCardProps) {
return ( return (
<div class="delete-hover-scope message-reasoning-card"> <div class="delete-hover-scope message-reasoning-card">
<div class="message-reasoning-header" ref={(el) => (headerEl = el)}> <div class="message-reasoning-header">
<button <button
type="button" type="button"
class="message-reasoning-toggle" class="message-reasoning-toggle"
@@ -1461,7 +1403,7 @@ function ReasoningCard(props: ReasoningCardProps) {
aria-label={expanded() ? t("messageBlock.reasoning.collapseAriaLabel") : t("messageBlock.reasoning.expandAriaLabel")} aria-label={expanded() ? t("messageBlock.reasoning.collapseAriaLabel") : t("messageBlock.reasoning.expandAriaLabel")}
> >
<span class="message-reasoning-label"> <span class="message-reasoning-label">
<span class="message-reasoning-label-primary" ref={(el) => (primaryEl = el)}> <span class="message-reasoning-label-primary">
<Show when={props.showDeleteMessage}> <Show when={props.showDeleteMessage}>
<input <input
class="message-select-checkbox" class="message-select-checkbox"
@@ -1482,43 +1424,10 @@ function ReasoningCard(props: ReasoningCardProps) {
<span>{t("messageBlock.reasoning.thinkingLabel")}</span> <span>{t("messageBlock.reasoning.thinkingLabel")}</span>
</span> </span>
<Show when={hasMeta() && showMetaInline()}>
<span class="message-step-meta-inline">
<Show when={agentIdentifier()}>
{(value) => (
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.agentLabel", { agent: value() })}</span>
)}
</Show>
<Show when={modelIdentifier()}>
{(value) => (
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.modelLabel", { model: value() })}</span>
)}
</Show>
</span>
</Show>
<Show when={hasMeta()}>
<span
ref={(el) => (metaMeasureEl = el)}
class="message-step-meta-inline message-step-meta-inline--measure"
>
<Show when={agentIdentifier()}>
{(value) => (
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.agentLabel", { agent: value() })}</span>
)}
</Show>
<Show when={modelIdentifier()}>
{(value) => (
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.modelLabel", { model: value() })}</span>
)}
</Show>
</span>
</Show>
</span> </span>
</button> </button>
<div class="message-reasoning-actions" ref={(el) => (actionsEl = el)}> <div class="message-reasoning-actions">
<button <button
type="button" type="button"
class="message-action-button" class="message-action-button"
@@ -1567,7 +1476,7 @@ function ReasoningCard(props: ReasoningCardProps) {
</div> </div>
</div> </div>
<Show when={hasMeta() && !showMetaInline()}> <Show when={hasMeta()}>
<div class="message-reasoning-meta-row"> <div class="message-reasoning-meta-row">
<span class="message-step-meta-inline"> <span class="message-step-meta-inline">
<Show when={agentIdentifier()}> <Show when={agentIdentifier()}>

View File

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

View File

@@ -30,14 +30,7 @@ export interface VirtualFollowListState {
export interface VirtualFollowListProps<T> { export interface VirtualFollowListProps<T> {
items: Accessor<T[]> items: Accessor<T[]>
getKey: (item: T, index: number) => string getKey: (item: T, index: number) => string
renderItem: ( renderItem: (item: T, index: number) => JSX.Element
item: T,
index: number,
options: {
registerMeasureElement: (element: HTMLElement | null) => void
notifyItemRendered: () => void
},
) => JSX.Element
/** /**
* Optional stable DOM id for the item wrapper. * Optional stable DOM id for the item wrapper.
@@ -381,7 +374,14 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
} }
function handleContentRendered() { function handleContentRendered() {
scheduleAnchorScroll() if (autoScroll() && !anchorLock()) {
scheduleAutoPinToBottom()
return
}
if (anchorLock() && !autoScroll()) {
scheduleAnchorCorrection()
return
}
} }
function handleScroll() { function handleScroll() {
@@ -521,25 +521,51 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
} }
let pendingAutoPin = false let pendingAutoPin = false
let pendingAutoPinFrame: number | null = null
function clearPendingAutoPinFrame() {
if (pendingAutoPinFrame !== null) {
cancelAnimationFrame(pendingAutoPinFrame)
pendingAutoPinFrame = null
}
}
function applyAutoPinToBottom() {
if (!containerRef) return false
if (!autoScroll()) return false
if (anchorLock()) return false
const maxScrollTop = Math.max(containerRef.scrollHeight - containerRef.clientHeight, 0)
if (containerRef.scrollTop !== maxScrollTop) {
containerRef.scrollTop = maxScrollTop
lastKnownScrollTop = maxScrollTop
}
return true
}
function scheduleAutoPinToBottom() { function scheduleAutoPinToBottom() {
if (!containerRef) return if (!containerRef) return
if (pendingAutoPin) return if (pendingAutoPin) return
pendingAutoPin = true pendingAutoPin = true
clearPendingAutoPinFrame()
const gen = scrollCompensationGen const gen = scrollCompensationGen
// Flush in a microtask so adjustments land before the next paint. // Flush in a microtask so adjustments land before the next paint,
// then re-apply on the next two frames to catch deferred layout.
queueMicrotask(() => { queueMicrotask(() => {
if (gen !== scrollCompensationGen) return if (gen !== scrollCompensationGen) return
pendingAutoPin = false pendingAutoPin = false
if (!containerRef) return if (!applyAutoPinToBottom()) return
if (!autoScroll()) return pendingAutoPinFrame = requestAnimationFrame(() => {
if (anchorLock()) return pendingAutoPinFrame = null
if (gen !== scrollCompensationGen) return
const maxScrollTop = Math.max(containerRef.scrollHeight - containerRef.clientHeight, 0) if (!applyAutoPinToBottom()) return
if (containerRef.scrollTop !== maxScrollTop) { pendingAutoPinFrame = requestAnimationFrame(() => {
containerRef.scrollTop = maxScrollTop pendingAutoPinFrame = null
lastKnownScrollTop = maxScrollTop if (gen !== scrollCompensationGen) return
} applyAutoPinToBottom()
})
})
}) })
} }
@@ -628,6 +654,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
pendingScrollCompensationScheduled = false pendingScrollCompensationScheduled = false
pendingScrollCompensations = new Map() pendingScrollCompensations = new Map()
pendingAutoPin = false pendingAutoPin = false
clearPendingAutoPinFrame()
suppressAutoScrollOnce = false suppressAutoScrollOnce = false
pendingActiveScroll = false pendingActiveScroll = false
@@ -718,7 +745,13 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
suppressAutoScrollOnce = false suppressAutoScrollOnce = false
return return
} }
if (autoScroll()) scheduleAnchorScroll(true) if (autoScroll()) {
scheduleAutoPinToBottom()
return
}
if (anchorLock() && !autoScroll()) {
scheduleAnchorCorrection()
}
}) })
// Drop anchor lock if the anchored key is removed. // Drop anchor lock if the anchored key is removed.
@@ -825,6 +858,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
scrollCompensationGen += 1 scrollCompensationGen += 1
pendingScrollCompensationScheduled = false pendingScrollCompensationScheduled = false
pendingScrollCompensations = new Map() pendingScrollCompensations = new Map()
clearPendingAutoPinFrame()
clearScrollToBottomFrames() clearScrollToBottomFrames()
if (detachScrollIntentListeners) { if (detachScrollIntentListeners) {
detachScrollIntentListeners() detachScrollIntentListeners()
@@ -888,35 +922,15 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
const anchorId = () => getAnchorId(key()) const anchorId = () => getAnchorId(key())
const overscanPx = props.overscanPx ?? 800 const overscanPx = props.overscanPx ?? 800
const suspendMeasurements = () => measurementsSuspended() || !isActive() const suspendMeasurements = () => measurementsSuspended() || !isActive()
const [measureElement, setMeasureElement] = createSignal<HTMLElement | undefined>() const itemVirtualizationEnabled = () => virtualizationEnabled() && !autoScroll()
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()}
cacheKey={key()} cacheKey={key()}
scrollContainer={scrollElement} scrollContainer={scrollElement}
threshold={overscanPx} threshold={overscanPx}
measureElement={measureElement}
contentRenderVersion={contentRenderVersion}
placeholderClass="message-stream-placeholder" placeholderClass="message-stream-placeholder"
virtualizationEnabled={virtualizationEnabled} virtualizationEnabled={itemVirtualizationEnabled}
suspendMeasurements={suspendMeasurements} suspendMeasurements={suspendMeasurements}
onHeightChange={(nextHeight, previousHeight, meta: VirtualItemHeightChangeMeta) => { onHeightChange={(nextHeight, previousHeight, meta: VirtualItemHeightChangeMeta) => {
const delta = nextHeight - previousHeight const delta = nextHeight - previousHeight
@@ -943,13 +957,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
scheduleScrollCompensation(key(), delta) scheduleScrollCompensation(key(), delta)
} }
}} }}
> >{() => props.renderItem(item(), index)}</VirtualItem>
{() =>
props.renderItem(item(), index, {
registerMeasureElement: (element) => setMeasureElement(element ?? undefined),
notifyItemRendered,
})}
</VirtualItem>
) )
}} }}
</Index> </Index>

View File

@@ -4,8 +4,6 @@ const sizeCache = new Map<string, number>()
const DEFAULT_MARGIN_PX = 600 const DEFAULT_MARGIN_PX = 600
const MIN_PLACEHOLDER_HEIGHT = 400 const MIN_PLACEHOLDER_HEIGHT = 400
const VISIBILITY_BUFFER_PX = 0 const VISIBILITY_BUFFER_PX = 0
const SETTLED_HEIGHT_EPSILON_PX = 1
const SETTLED_HEIGHT_FRAMES = 2
type ObserverRoot = Element | Document | null type ObserverRoot = Element | Document | null
@@ -162,8 +160,6 @@ interface VirtualItemProps {
scrollContainer?: Accessor<HTMLElement | undefined | null> scrollContainer?: Accessor<HTMLElement | undefined | null>
threshold?: number threshold?: number
minPlaceholderHeight?: number minPlaceholderHeight?: number
measureElement?: Accessor<HTMLElement | undefined | null>
contentRenderVersion?: Accessor<number>
class?: string class?: string
contentClass?: string contentClass?: string
placeholderClass?: string placeholderClass?: string
@@ -182,8 +178,6 @@ 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)
@@ -196,8 +190,6 @@ 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 [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
@@ -214,11 +206,6 @@ export default function VirtualItem(props: VirtualItemProps) {
} }
} }
const queueVisibility = (nextValue: boolean) => { const queueVisibility = (nextValue: boolean) => {
if (nextValue && !isIntersecting()) {
setSettlingMode("hidden-to-visible")
} else if (!nextValue) {
setSettlingMode(null)
}
pendingVisibility = nextValue pendingVisibility = nextValue
if (visibilityFrame !== null) return if (visibilityFrame !== null) return
visibilityFrame = requestAnimationFrame(() => { visibilityFrame = requestAnimationFrame(() => {
@@ -231,22 +218,18 @@ export default function VirtualItem(props: VirtualItemProps) {
} }
const virtualizationEnabled = () => (props.virtualizationEnabled ? props.virtualizationEnabled() : true) const virtualizationEnabled = () => (props.virtualizationEnabled ? props.virtualizationEnabled() : true)
const measurementsSuspended = () => Boolean(props.suspendMeasurements?.()) const measurementsSuspended = () => Boolean(props.suspendMeasurements?.())
const forceVisible = () => Boolean(props.forceVisible?.())
const shouldHideContent = createMemo(() => { const shouldHideContent = createMemo(() => {
if (props.forceVisible?.()) return false if (forceVisible()) return false
if (!virtualizationEnabled()) return false if (!virtualizationEnabled()) return false
return !isIntersecting() return !isIntersecting()
}) })
const shouldHideMountedContent = createMemo(() => shouldHideContent() || settlingMode() === "hidden-to-visible")
let wrapperRef: HTMLDivElement | undefined let wrapperRef: HTMLDivElement | undefined
let contentRef: HTMLDivElement | undefined let contentRef: HTMLDivElement | undefined
let resizeObserver: ResizeObserver | undefined let resizeObserver: ResizeObserver | undefined
let intersectionCleanup: (() => void) | 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() { function cleanupResizeObserver() {
if (resizeObserver) { if (resizeObserver) {
@@ -255,104 +238,15 @@ export default function VirtualItem(props: VirtualItemProps) {
} }
} }
function clearDelayedMeasureFrames() { function scheduleVisibleMeasurements() {
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 getVisibleSettlingMode(): VisibleSettlingMode {
return awaitingVisibleMeasurement ? "hidden-to-visible" : "visible-rerender"
}
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
setSettlingMode(null)
persistMeasurement(next, { source: measurementSource, wasHidden })
}
function scheduleSettledVisibleMeasurement(mode = getVisibleSettlingMode()) {
if (shouldHideContent() || measurementsSuspended()) return
if (!contentRef) return
setSettlingMode(mode)
clearSettlingMeasurementFrame()
const tick = () => {
settlingMeasureFrame = requestAnimationFrame(() => {
settlingMeasureFrame = null
if (shouldHideContent() || measurementsSuspended()) {
setSettlingMode(null)
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 (shouldHideContent() || measurementsSuspended()) return
if (!contentRef) return if (!contentRef) return
queueMicrotask(() => { queueMicrotask(() => {
if (shouldHideContent() || measurementsSuspended()) return if (shouldHideContent() || measurementsSuspended()) return
if (!contentRef) return if (!contentRef) return
updateMeasuredHeight()
setupResizeObserver() setupResizeObserver()
scheduleSettledVisibleMeasurement(getVisibleSettlingMode())
}) })
scheduleDelayedVisibleMeasurements()
} }
function cleanupIntersectionObserver() { function cleanupIntersectionObserver() {
@@ -362,69 +256,6 @@ export default function VirtualItem(props: VirtualItemProps) {
} }
} }
function getMeasuredContentRect(): { rect: DOMRect } | null {
const explicitMeasureElement = props.measureElement?.()
if (explicitMeasureElement?.isConnected) {
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 }) { function persistMeasurement(nextHeight: number, meta?: { source: "initial-visible-measure" | "resize"; wasHidden: boolean }) {
if (!Number.isFinite(nextHeight) || nextHeight < 0) { if (!Number.isFinite(nextHeight) || nextHeight < 0) {
return return
@@ -466,11 +297,6 @@ export default function VirtualItem(props: VirtualItemProps) {
} }
} }
setMeasuredHeight(normalized) setMeasuredHeight(normalized)
if (measurementMeta.isStaleCacheCorrection) {
requestAnimationFrame(() => {
recheckVisibilityAfterMeasurement()
})
}
if (normalized !== before) props.onHeightChange?.(normalized, before, measurementMeta) if (normalized !== before) props.onHeightChange?.(normalized, before, measurementMeta)
} }
@@ -479,14 +305,16 @@ export default function VirtualItem(props: VirtualItemProps) {
if (measurementsSuspended()) return if (measurementsSuspended()) return
// Prefer subpixel-accurate height for scroll compensation. // Prefer subpixel-accurate height for scroll compensation.
// offsetHeight rounds to integers which can accumulate error. // offsetHeight rounds to integers which can accumulate error.
const measurement = getMeasuredContentRect() const rect = contentRef.getBoundingClientRect()
if (!measurement) return
const { rect } = measurement
const next = Math.max(0, Math.round(rect.height * 2) / 2) const next = Math.max(0, Math.round(rect.height * 2) / 2)
const currentMeasured = measuredHeight() const currentMeasured = measuredHeight()
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
if (measurementSource === "initial-visible-measure") {
awaitingVisibleMeasurement = false
lastMeasurementWhileHidden = false
}
if (next === currentMeasured) return
persistMeasurement(next, { source: measurementSource, wasHidden }) persistMeasurement(next, { source: measurementSource, wasHidden })
} }
@@ -499,7 +327,6 @@ export default function VirtualItem(props: VirtualItemProps) {
} }
resizeObserver = new ResizeObserver(() => { resizeObserver = new ResizeObserver(() => {
if (measurementsSuspended()) return if (measurementsSuspended()) return
if (isSettlingVisible()) return
updateMeasuredHeight() updateMeasuredHeight()
}) })
resizeObserver.observe(contentRef) resizeObserver.observe(contentRef)
@@ -551,17 +378,11 @@ export default function VirtualItem(props: VirtualItemProps) {
} }
try { try {
const rootRect = (targetRoot as Element).getBoundingClientRect() const rootRect = (targetRoot as Element).getBoundingClientRect()
const wrapperRect = wrapperEl.getBoundingClientRect()
const visible = shouldRenderByRects({ const visible = shouldRenderByRects({
wrapperRect, wrapperRect: wrapperEl.getBoundingClientRect(),
rootRect: { top: rootRect.top, bottom: rootRect.bottom }, rootRect: { top: rootRect.top, bottom: rootRect.bottom },
margin, margin,
}) })
const collapsedWhileVisible = isIntersecting() && !visible && Math.round(wrapperRect.height) <= 0 && measuredHeight() > 0
if (collapsedWhileVisible) {
queueVisibility(true)
return
}
queueVisibility(visible) queueVisibility(visible)
return return
} catch { } catch {
@@ -574,37 +395,6 @@ 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) { function setWrapperRef(element: HTMLDivElement | null) {
wrapperRef = element ?? undefined wrapperRef = element ?? undefined
const root = props.scrollContainer ? props.scrollContainer() : null const root = props.scrollContainer ? props.scrollContainer() : null
@@ -628,27 +418,14 @@ export default function VirtualItem(props: VirtualItemProps) {
if (hidden) { if (hidden) {
awaitingVisibleMeasurement = true awaitingVisibleMeasurement = true
lastMeasurementWhileHidden = true lastMeasurementWhileHidden = true
clearDelayedMeasureFrames()
clearSettlingMeasurementFrame()
setSettlingMode(null)
} }
if (hidden || measurementsSuspended()) { if (hidden || measurementsSuspended()) {
cleanupResizeObserver() cleanupResizeObserver()
} }
if (!hidden && !measurementsSuspended() && contentRef) { if (!hidden && !measurementsSuspended() && contentRef) {
queueMicrotask(() => { scheduleVisibleMeasurements()
setupResizeObserver()
scheduleSettledVisibleMeasurement(getVisibleSettlingMode())
})
scheduleDelayedVisibleMeasurements()
} }
}) })
createEffect(() => {
const version = props.contentRenderVersion?.()
if (version === undefined) return
if (version <= 0) return
scheduleContentRenderedMeasurements()
})
createEffect(() => { createEffect(() => {
@@ -680,15 +457,13 @@ export default function VirtualItem(props: VirtualItemProps) {
onCleanup(() => { onCleanup(() => {
cleanupResizeObserver() cleanupResizeObserver()
cleanupIntersectionObserver() cleanupIntersectionObserver()
clearDelayedMeasureFrames()
clearSettlingMeasurementFrame()
flushVisibility() flushVisibility()
}) })
const wrapperClass = () => ["virtual-item-wrapper", props.class].filter(Boolean).join(" ") const wrapperClass = () => ["virtual-item-wrapper", props.class].filter(Boolean).join(" ")
const contentClass = () => { const contentClass = () => {
const classes = ["virtual-item-content", props.contentClass] const classes = ["virtual-item-content", props.contentClass]
if (shouldHideMountedContent()) { if (shouldHideContent()) {
classes.push("virtual-item-content-hidden") classes.push("virtual-item-content-hidden")
} }
return classes.filter(Boolean).join(" ") return classes.filter(Boolean).join(" ")
@@ -705,7 +480,7 @@ export default function VirtualItem(props: VirtualItemProps) {
class={placeholderClass()} class={placeholderClass()}
style={{ style={{
width: "100%", width: "100%",
height: shouldHideMountedContent() ? `${placeholderHeight()}px` : undefined, height: shouldHideContent() ? `${placeholderHeight()}px` : undefined,
}} }}
> >
<div ref={setContentRef} class={contentClass()}> <div ref={setContentRef} class={contentClass()}>