fix(ui): stabilize virtual list scroll compensation

This commit is contained in:
Shantur Rathore
2026-03-03 21:23:50 +00:00
parent 133e937772
commit 8f955cf21c
3 changed files with 28 additions and 18 deletions

View File

@@ -402,8 +402,13 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
if (!anchor) return if (!anchor) return
const containerRect = containerRef.getBoundingClientRect() const containerRect = containerRef.getBoundingClientRect()
const rect = anchor.getBoundingClientRect() const rect = anchor.getBoundingClientRect()
const isAboveViewport = rect.bottom < containerRect.top // Determine whether the item was fully above the viewport *before* the
if (!isAboveViewport) { // height delta applied. Items can expand downward into the viewport; in that
// case we still need to compensate to keep existing visible content stable.
const bottomAfter = rect.bottom
const bottomBefore = bottomAfter - delta
const wasAboveViewport = bottomBefore < containerRect.top
if (!wasAboveViewport) {
return return
} }
@@ -445,7 +450,6 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
containerRef.scrollTop = nextTop containerRef.scrollTop = nextTop
lastKnownScrollTop = nextTop lastKnownScrollTop = nextTop
} }
}) })
} }
@@ -531,23 +535,23 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
let lastActiveState = false let lastActiveState = false
createEffect(() => { createEffect(() => {
const active = isActive() const active = isActive()
if (active) { if (active) {
resolvePendingActiveScroll() resolvePendingActiveScroll()
if (!lastActiveState && autoScroll()) { if (!lastActiveState && autoScroll()) {
requestScrollToBottom(true) requestScrollToBottom(true)
// When switching back to a cached session pane, items can mount/measure // When switching back to a cached session pane, items can mount/measure
// after the initial scroll jump. Re-pin once layout settles so the // after the initial scroll jump. Re-pin once layout settles so the
// viewport stays at the bottom. // viewport stays at the bottom.
requestAnimationFrame(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
requestAnimationFrame(() => { scheduleAutoPinToBottom()
scheduleAutoPinToBottom()
})
}) })
} })
} else if (autoScroll()) {
pendingActiveScroll = true
} }
} else if (autoScroll()) {
pendingActiveScroll = true
}
lastActiveState = active lastActiveState = active
}) })

View File

@@ -174,11 +174,15 @@ interface VirtualItemProps {
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)
const fallbackPlaceholderHeight = () => props.minPlaceholderHeight ?? MIN_PLACEHOLDER_HEIGHT
// Default to hidden until we can determine visibility. // Default to hidden until we can determine visibility.
// This avoids keeping heavy DOM alive when IntersectionObserver // This avoids keeping heavy DOM alive when IntersectionObserver
// doesn't fire (common for hidden/zero-sized scroll roots). // doesn't fire (common for hidden/zero-sized scroll roots).
const [isIntersecting, setIsIntersecting] = createSignal(false) const [isIntersecting, setIsIntersecting] = createSignal(false)
const [measuredHeight, setMeasuredHeight] = createSignal(cachedHeight ?? 0) // Keep measuredHeight aligned with the *effective layout height* while hidden.
// 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 [hasMeasured, setHasMeasured] = createSignal(cachedHeight !== undefined)
let hasReportedMeasurement = Boolean(cachedHeight && cachedHeight > 0) let hasReportedMeasurement = Boolean(cachedHeight && cachedHeight > 0)
let pendingVisibility: boolean | null = null let pendingVisibility: boolean | null = null
@@ -395,7 +399,7 @@ export default function VirtualItem(props: VirtualItemProps) {
setMeasuredHeight(cached) setMeasuredHeight(cached)
setHasMeasured(true) setHasMeasured(true)
} else { } else {
setMeasuredHeight(0) setMeasuredHeight(fallbackPlaceholderHeight())
setHasMeasured(false) setHasMeasured(false)
} }
}) })

View File

@@ -2,6 +2,8 @@
@apply flex-1 min-h-0 overflow-y-auto flex flex-col gap-0.5; @apply flex-1 min-h-0 overflow-y-auto flex flex-col gap-0.5;
background-color: var(--surface-base); background-color: var(--surface-base);
color: inherit; color: inherit;
/* Prevent browser scroll anchoring fighting our virtualization compensation. */
overflow-anchor: none;
} }
.message-stream-block { .message-stream-block {