diff --git a/packages/ui/src/components/virtual-follow-list.tsx b/packages/ui/src/components/virtual-follow-list.tsx index f173f92d..b6020c7e 100644 --- a/packages/ui/src/components/virtual-follow-list.tsx +++ b/packages/ui/src/components/virtual-follow-list.tsx @@ -402,8 +402,13 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { if (!anchor) return const containerRect = containerRef.getBoundingClientRect() const rect = anchor.getBoundingClientRect() - const isAboveViewport = rect.bottom < containerRect.top - if (!isAboveViewport) { + // Determine whether the item was fully above the viewport *before* the + // 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 } @@ -445,7 +450,6 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { containerRef.scrollTop = nextTop lastKnownScrollTop = nextTop } - }) } @@ -531,23 +535,23 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { let lastActiveState = false createEffect(() => { const active = isActive() - if (active) { - resolvePendingActiveScroll() - if (!lastActiveState && autoScroll()) { - requestScrollToBottom(true) + if (active) { + resolvePendingActiveScroll() + if (!lastActiveState && autoScroll()) { + requestScrollToBottom(true) - // When switching back to a cached session pane, items can mount/measure - // after the initial scroll jump. Re-pin once layout settles so the - // viewport stays at the bottom. + // When switching back to a cached session pane, items can mount/measure + // after the initial scroll jump. Re-pin once layout settles so the + // viewport stays at the bottom. + requestAnimationFrame(() => { requestAnimationFrame(() => { - requestAnimationFrame(() => { - scheduleAutoPinToBottom() - }) + scheduleAutoPinToBottom() }) - } - } else if (autoScroll()) { - pendingActiveScroll = true + }) } + } else if (autoScroll()) { + pendingActiveScroll = true + } lastActiveState = active }) diff --git a/packages/ui/src/components/virtual-item.tsx b/packages/ui/src/components/virtual-item.tsx index 4feb0931..5c0c6c42 100644 --- a/packages/ui/src/components/virtual-item.tsx +++ b/packages/ui/src/components/virtual-item.tsx @@ -174,11 +174,15 @@ interface VirtualItemProps { 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) + const fallbackPlaceholderHeight = () => props.minPlaceholderHeight ?? MIN_PLACEHOLDER_HEIGHT // Default to hidden until we can determine visibility. // This avoids keeping heavy DOM alive when IntersectionObserver // doesn't fire (common for hidden/zero-sized scroll roots). 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) let hasReportedMeasurement = Boolean(cachedHeight && cachedHeight > 0) let pendingVisibility: boolean | null = null @@ -395,7 +399,7 @@ export default function VirtualItem(props: VirtualItemProps) { setMeasuredHeight(cached) setHasMeasured(true) } else { - setMeasuredHeight(0) + setMeasuredHeight(fallbackPlaceholderHeight()) setHasMeasured(false) } }) diff --git a/packages/ui/src/styles/messaging/virtual-follow-list.css b/packages/ui/src/styles/messaging/virtual-follow-list.css index 77808aac..5dbea1be 100644 --- a/packages/ui/src/styles/messaging/virtual-follow-list.css +++ b/packages/ui/src/styles/messaging/virtual-follow-list.css @@ -2,6 +2,8 @@ @apply flex-1 min-h-0 overflow-y-auto flex flex-col gap-0.5; background-color: var(--surface-base); color: inherit; + /* Prevent browser scroll anchoring fighting our virtualization compensation. */ + overflow-anchor: none; } .message-stream-block {