fix(ui): stabilize virtual list scroll compensation
This commit is contained in:
@@ -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
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user