Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 39 additions & 6 deletions packages/ui/src/components/virtual-follow-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
let detachScrollIntentListeners: (() => void) | undefined
let lastResetKey: string | number | undefined
let suppressAutoScrollOnce = false
let pendingUpwardBreakUntil = 0
let pendingInitialScroll = true
let lastObservedScrollOffset = 0
let lastObservedPinnedAtBottom = false
Expand All @@ -190,13 +191,28 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
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
}
}
}

function hasUserScrollIntent() {
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)
Expand Down Expand Up @@ -269,28 +285,37 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
// 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
}

// 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
}
Expand Down Expand Up @@ -394,11 +419,18 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
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(),
Expand All @@ -411,6 +443,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
itemElements.clear()
setActiveHoldTargetKey(null)
setDidTriggerHoldForCurrentTarget(false)
clearPendingUpwardBreak()
lastObservedScrollOffset = 0
lastObservedPinnedAtBottom = false
}))
Expand All @@ -426,15 +459,15 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {

// 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
}, { defer: true }))

// Handle followToken change
createEffect(on(() => props.followToken?.(), () => {
if (autoScroll() && !effectiveSuspendAutoPinToBottom()) {
if (autoScroll() && !effectiveSuspendAutoPinToBottom() && !hasPendingUpwardBreak()) {
scrollToBottom(true)
}
}, { defer: true }))
Expand Down
Loading