From a795869064b1e122b47123fe6897dd61ec1535aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Fri, 17 Apr 2026 07:36:00 +0200 Subject: [PATCH] fix(ui): stabilize timeline follow scroll from bottom (#327) ## Summary - fix the sticky-bottom state where dragging the scrollbar to the bottom makes `PageUp` jump to the previous timeline block and then snap immediately back down - keep the change scoped to `virtual-follow-list.tsx`, where follow mode, scroll intent, and bottom pinning are coordinated ## Root Cause The list only disabled follow mode when it saw an explicit local "user intent" signal. After reaching the bottom through the native scrollbar, `PageUp` could move the viewport without tripping that path, so the next render notification re-enabled the bottom snap immediately. ## Validation - `npx tsc --noEmit --project packages/ui/tsconfig.json` - `npm run build --prefix packages/ui` - manual desktop test: `PageUp` works again from the bottom sticky state --- .../ui/src/components/virtual-follow-list.tsx | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/ui/src/components/virtual-follow-list.tsx b/packages/ui/src/components/virtual-follow-list.tsx index c26ad97d..bfc3558a 100644 --- a/packages/ui/src/components/virtual-follow-list.tsx +++ b/packages/ui/src/components/virtual-follow-list.tsx @@ -174,6 +174,8 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { let lastResetKey: string | number | undefined let suppressAutoScrollOnce = false let pendingInitialScroll = true + let lastObservedScrollOffset = 0 + let lastObservedPinnedAtBottom = false const state: VirtualFollowListState = { autoScroll, @@ -239,15 +241,29 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { if (!handle || !element) return const offset = handle.scrollOffset + const scrolledUp = offset < lastObservedScrollOffset - 1 + const wasPinnedAtBottom = lastObservedPinnedAtBottom const scrollHeight = handle.scrollSize const clientHeight = element.clientHeight const atBottom = scrollHeight - (offset + clientHeight) <= (props.scrollSentinelMarginPx ?? DEFAULT_SCROLL_SENTINEL_MARGIN_PX) const atTop = offset <= (props.scrollSentinelMarginPx ?? DEFAULT_SCROLL_SENTINEL_MARGIN_PX) + lastObservedScrollOffset = offset const hasItems = props.items().length > 0 setShowScrollBottomButton(hasItems && !atBottom) setShowScrollTopButton(hasItems && !atTop) + // Keyboard/PageUp scrolls can move the viewport without ever hitting our + // local key intent listeners (for example after dragging the native + // 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 && heldItemCount() === null) { + setAutoScroll(false) + lastObservedPinnedAtBottom = false + return + } + // Sync autoScroll state based on scroll position if it was a user scroll if (hasUserScrollIntent()) { if (atBottom && heldItemCount() !== null) { @@ -259,6 +275,8 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { setAutoScroll(false) } } + + lastObservedPinnedAtBottom = autoScroll() && atBottom } function scrollToBottom(immediate = true, options?: { suppressAutoAnchor?: boolean }) { @@ -395,6 +413,8 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { createEffect(on(() => props.resetKey?.(), () => { itemElements.clear() setHeldItemCount(null) + lastObservedScrollOffset = 0 + lastObservedPinnedAtBottom = false })) // Handle autoScroll (Follow) on items change