diff --git a/packages/ui/src/components/virtual-follow-list.tsx b/packages/ui/src/components/virtual-follow-list.tsx index 070e0b92..05a7d205 100644 --- a/packages/ui/src/components/virtual-follow-list.tsx +++ b/packages/ui/src/components/virtual-follow-list.tsx @@ -173,6 +173,7 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { let detachScrollIntentListeners: (() => void) | undefined let lastResetKey: string | number | undefined let suppressAutoScrollOnce = false + let pendingUpwardBreakUntil = 0 let pendingInitialScroll = true let lastObservedScrollOffset = 0 let lastObservedPinnedAtBottom = false @@ -190,6 +191,13 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { userScrollIntentUntil = now + USER_SCROLL_INTENT_WINDOW_MS if (direction) { lastUserScrollIntentDirection = direction + if (direction === "up" && autoScroll() && activeHoldTargetKey() === null) { + // Streaming renders can re-pin before the scroll event is observed. + // Suppress the next automatic bottom pin, but only break follow once a + // real upward movement away from bottom is confirmed. + pendingUpwardBreakUntil = now + USER_SCROLL_INTENT_WINDOW_MS + suppressAutoScrollOnce = true + } } } @@ -197,6 +205,14 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { return performance.now() <= userScrollIntentUntil } + function hasPendingUpwardBreak() { + return performance.now() <= pendingUpwardBreakUntil + } + + function clearPendingUpwardBreak() { + pendingUpwardBreakUntil = 0 + } + function clearAutoPinHold(options?: { resumeBottom?: boolean }) { if (activeHoldTargetKey() === null) return setActiveHoldTargetKey(null) @@ -269,8 +285,9 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { // scrollbar). If follow mode stays enabled, the next render notification // snaps the list straight back to bottom. A real upward viewport move away // from bottom should always break follow unless a hold target is active. - if (wasPinnedAtBottom && scrolledUp && autoScroll() && !atBottom && activeHoldTargetKey() === null) { + if (wasPinnedAtBottom && scrolledUp && (autoScroll() || hasPendingUpwardBreak()) && !atBottom && activeHoldTargetKey() === null) { setAutoScroll(false) + clearPendingUpwardBreak() lastObservedPinnedAtBottom = false return } @@ -278,19 +295,27 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { // Sync autoScroll state based on scroll position if it was a user scroll if (hasUserScrollIntent()) { clearAutoPinHold() - if (atBottom && !autoScroll()) { - setAutoScroll(true) + if (atBottom) { + clearPendingUpwardBreak() + if (!autoScroll()) { + setAutoScroll(true) + } } else if (!atBottom && autoScroll()) { setAutoScroll(false) } } + if (atBottom && !hasUserScrollIntent()) { + clearPendingUpwardBreak() + } + lastObservedPinnedAtBottom = autoScroll() && atBottom } function scrollToBottom(immediate = true, options?: { suppressAutoAnchor?: boolean }) { const handle = virtuaHandle() if (!handle) return + clearPendingUpwardBreak() if (options?.suppressAutoAnchor ?? !immediate) { suppressAutoScrollOnce = true } @@ -394,11 +419,18 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { notifyContentRendered: () => { updateAutoPinHold() if (activeHoldTargetKey() !== null) return + if (hasPendingUpwardBreak()) return if (autoScroll() && !effectiveSuspendAutoPinToBottom()) { scrollToBottom(true) } }, - setAutoScroll: (enabled) => setAutoScroll(Boolean(enabled)), + setAutoScroll: (enabled) => { + const nextEnabled = Boolean(enabled) + if (nextEnabled) { + clearPendingUpwardBreak() + } + setAutoScroll(nextEnabled) + }, getAutoScroll: () => autoScroll(), getScrollElement: () => scrollElement(), getShellElement: () => shellElement(), @@ -411,6 +443,7 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { itemElements.clear() setActiveHoldTargetKey(null) setDidTriggerHoldForCurrentTarget(false) + clearPendingUpwardBreak() lastObservedScrollOffset = 0 lastObservedPinnedAtBottom = false })) @@ -426,7 +459,7 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { // Handle autoScroll (Follow) on items change createEffect(on(() => props.items().length, (len, prevLen) => { - if (len > (prevLen ?? 0) && autoScroll() && !effectiveSuspendAutoPinToBottom() && !suppressAutoScrollOnce) { + if (len > (prevLen ?? 0) && autoScroll() && !effectiveSuspendAutoPinToBottom() && !suppressAutoScrollOnce && !hasPendingUpwardBreak()) { requestAnimationFrame(() => scrollToBottom(true)) } suppressAutoScrollOnce = false @@ -434,7 +467,7 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { // Handle followToken change createEffect(on(() => props.followToken?.(), () => { - if (autoScroll() && !effectiveSuspendAutoPinToBottom()) { + if (autoScroll() && !effectiveSuspendAutoPinToBottom() && !hasPendingUpwardBreak()) { scrollToBottom(true) } }, { defer: true }))