summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--packages/ui/src/hooks/create-auto-scroll.tsx47
1 files changed, 46 insertions, 1 deletions
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<typeof setTimeout> | undefined
+ let autoTimer: ReturnType<typeof setTimeout> | 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()
})