Files
multica/packages/ui/hooks/use-auto-scroll.ts
Bohan Jiang 90455abd8d fix(desktop): preserve tab scroll position across Activity visibility cycles (MUL-2602) (#3196)
Closes #3183.

Tabs render under `<Activity mode="visible|hidden">`, which keeps React
state but drops DOM scrollTop when the subtree leaves layout. Switching
to another tab and back sent users to the top of long discussions.

`useTabScrollRestore` records the scrollTop of every element marked with
`data-tab-scroll-root` while the tab is visible (capture-phase scroll
listener) and restores them in a useLayoutEffect on the next visible
transition, before paint. Saved offsets are dropped when the tab's path
changes so intra-tab navigation lands at scroll=0 instead of inheriting
the previous route's position.

Mark scroll containers in views with `data-tab-scroll-root` (issue
detail + chat message list ship with the marker; other views can adopt
the convention as needed).

`useAutoScroll` previously called `scrollToBottom()` on every effect
mount, which would have overwritten the restored offset every time a
chat tab cycled back to visible. Guard it with a once-per-instance ref.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-25 15:31:01 +08:00

81 lines
2.4 KiB
TypeScript

import { type RefObject, useEffect, useRef, useCallback } from "react"
/**
* Auto-scrolls a scroll container to the bottom when its inner content grows,
* as long as the user hasn't scrolled up to read older content.
*
* Returns a `lockRef` that can be set to `true` to temporarily suppress
* auto-scroll (e.g. during history prepend operations).
*/
export function useAutoScroll(ref: RefObject<HTMLElement | null>) {
const stickRef = useRef(true)
const lockRef = useRef(false)
// Re-running the initial scroll-to-bottom on every effect mount would
// overwrite the scroll position any time React tears the effect down and
// brings it back — e.g. when the host tab cycles through `<Activity
// mode="hidden">` and back. We want the jump only on a real first mount.
const didInitialScrollRef = useRef(false)
useEffect(() => {
const el = ref.current
if (!el) return
const scrollToBottom = () => {
el.scrollTo({ top: el.scrollHeight })
}
const onScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = el
stickRef.current = scrollHeight - scrollTop - clientHeight < 50
}
const onContentChange = () => {
if (lockRef.current) return
if (stickRef.current) {
scrollToBottom()
}
}
// Watch child element resizes (content growth, image loads, streaming)
const ro = new ResizeObserver(onContentChange)
for (const child of el.children) {
ro.observe(child)
}
// Watch for added/removed child nodes (new messages rendered)
const mo = new MutationObserver((mutations) => {
// Also observe newly added elements
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node instanceof Element) {
ro.observe(node)
}
}
}
onContentChange()
})
mo.observe(el, { childList: true, subtree: true })
el.addEventListener("scroll", onScroll, { passive: true })
if (!didInitialScrollRef.current) {
didInitialScrollRef.current = true
scrollToBottom()
}
return () => {
el.removeEventListener("scroll", onScroll)
ro.disconnect()
mo.disconnect()
}
}, [ref])
/** Temporarily suppress auto-scroll during prepend operations */
const suppressAutoScroll = useCallback(() => {
lockRef.current = true
return () => { lockRef.current = false }
}, [])
return { suppressAutoScroll }
}