From c69e3bbde761db25710e6b5d44d0398a0470eeb5 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 21 Jan 2026 12:52:52 -0600 Subject: [PATCH] fix(app): auto-scroll ux --- packages/ui/src/hooks/create-auto-scroll.tsx | 47 +++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/hooks/create-auto-scroll.tsx b/packages/ui/src/hooks/create-auto-scroll.tsx index b74fb699d..26cd06e88 100644 --- a/packages/ui/src/hooks/create-auto-scroll.tsx +++ b/packages/ui/src/hooks/create-auto-scroll.tsx @@ -13,8 +13,10 @@ export function createAutoScroll(options: AutoScrollOptions) { let scroll: HTMLElement | undefined let settling = false let settleTimer: ReturnType | undefined + let autoTimer: ReturnType | undefined let cleanup: (() => void) | undefined let resizeFrame: number | undefined + let auto: { top: number; time: number } | undefined const threshold = () => options.bottomThreshold ?? 10 @@ -29,10 +31,46 @@ export function createAutoScroll(options: AutoScrollOptions) { return el.scrollHeight - el.clientHeight - el.scrollTop } + // Browsers can dispatch scroll events asynchronously. If new content arrives + // between us calling `scrollTo()` and the subsequent `scroll` event firing, + // the handler can see a non-zero `distanceFromBottom` and incorrectly assume + // the user scrolled. + const markAuto = (el: HTMLElement) => { + auto = { + top: Math.max(0, el.scrollHeight - el.clientHeight), + time: Date.now(), + } + + if (autoTimer) clearTimeout(autoTimer) + autoTimer = setTimeout(() => { + auto = undefined + autoTimer = undefined + }, 250) + } + + const isAuto = (el: HTMLElement) => { + const a = auto + if (!a) return false + + if (Date.now() - a.time > 250) { + auto = undefined + return false + } + + return Math.abs(el.scrollTop - a.top) < 2 + } + const scrollToBottomNow = (behavior: ScrollBehavior) => { const el = scroll if (!el) return - el.scrollTo({ top: el.scrollHeight, behavior }) + markAuto(el) + if (behavior === "smooth") { + el.scrollTo({ top: el.scrollHeight, behavior }) + return + } + + // `scrollTop` assignment bypasses any CSS `scroll-behavior: smooth`. + el.scrollTop = el.scrollHeight } const scrollToBottom = (force: boolean) => { @@ -79,6 +117,12 @@ export function createAutoScroll(options: AutoScrollOptions) { return } + // Ignore scroll events triggered by our own scrollToBottom calls. + if (!store.userScrolled && isAuto(el)) { + scrollToBottom(false) + return + } + stop() } @@ -145,6 +189,7 @@ export function createAutoScroll(options: AutoScrollOptions) { onCleanup(() => { if (settleTimer) clearTimeout(settleTimer) + if (autoTimer) clearTimeout(autoTimer) if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame) if (cleanup) cleanup() })