fix: stabilize home scroll behavior (#11576)

* feat(dom): extend scrollIntoView with Chromium-specific options

Add ChromiumScrollIntoViewOptions interface to support additional scroll container options

* refactor(hooks): optimize timer and scroll position hooks

- Use useMemo for scrollKey in useScrollPosition to avoid unnecessary recalculations
- Refactor useTimer to use useCallback for all functions to prevent unnecessary recreations
- Reorganize function order and improve cleanup logic in useTimer

* fix: stabilize home scroll behavior

* Update src/renderer/src/pages/home/Messages/ChatNavigation.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix(utils/dom): add null check for element in scrollIntoView

Prevent potential runtime errors by gracefully handling falsy elements with a warning log

* fix(hooks): use ref for scroll key to avoid stale closure

* fix(useScrollPosition): add cleanup for scroll handler to prevent memory leaks

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Phantom 2025-11-30 17:31:32 +08:00 committed by GitHub
parent 706fac898a
commit 03ff6e1ca6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 99 additions and 60 deletions

View File

@ -1,5 +1,5 @@
import { throttle } from 'lodash' import { throttle } from 'lodash'
import { useEffect, useRef } from 'react' import { useEffect, useMemo, useRef } from 'react'
import { useTimer } from './useTimer' import { useTimer } from './useTimer'
@ -12,13 +12,18 @@ import { useTimer } from './useTimer'
*/ */
export default function useScrollPosition(key: string, throttleWait?: number) { export default function useScrollPosition(key: string, throttleWait?: number) {
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const scrollKey = `scroll:${key}` const scrollKey = useMemo(() => `scroll:${key}`, [key])
const scrollKeyRef = useRef(scrollKey)
const { setTimeoutTimer } = useTimer() const { setTimeoutTimer } = useTimer()
useEffect(() => {
scrollKeyRef.current = scrollKey
}, [scrollKey])
const handleScroll = throttle(() => { const handleScroll = throttle(() => {
const position = containerRef.current?.scrollTop ?? 0 const position = containerRef.current?.scrollTop ?? 0
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
window.keyv.set(scrollKey, position) window.keyv.set(scrollKeyRef.current, position)
}) })
}, throttleWait ?? 100) }, throttleWait ?? 100)
@ -28,5 +33,9 @@ export default function useScrollPosition(key: string, throttleWait?: number) {
setTimeoutTimer('scrollEffect', scroll, 50) setTimeoutTimer('scrollEffect', scroll, 50)
}, [scrollKey, setTimeoutTimer]) }, [scrollKey, setTimeoutTimer])
useEffect(() => {
return () => handleScroll.cancel()
}, [handleScroll])
return { containerRef, handleScroll } return { containerRef, handleScroll }
} }

View File

@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react' import { useCallback, useEffect, useRef } from 'react'
/** /**
* Hook setTimeout setInterval key * Hook setTimeout setInterval key
@ -43,10 +43,38 @@ export const useTimer = () => {
const timeoutMapRef = useRef(new Map<string, NodeJS.Timeout>()) const timeoutMapRef = useRef(new Map<string, NodeJS.Timeout>())
const intervalMapRef = useRef(new Map<string, NodeJS.Timeout>()) const intervalMapRef = useRef(new Map<string, NodeJS.Timeout>())
/**
* key setTimeout
* @param key -
*/
const clearTimeoutTimer = useCallback((key: string) => {
clearTimeout(timeoutMapRef.current.get(key))
timeoutMapRef.current.delete(key)
}, [])
/**
* key setInterval
* @param key -
*/
const clearIntervalTimer = useCallback((key: string) => {
clearInterval(intervalMapRef.current.get(key))
intervalMapRef.current.delete(key)
}, [])
/**
* setTimeout setInterval
*/
const clearAllTimers = useCallback(() => {
timeoutMapRef.current.forEach((timer) => clearTimeout(timer))
intervalMapRef.current.forEach((timer) => clearInterval(timer))
timeoutMapRef.current.clear()
intervalMapRef.current.clear()
}, [])
// 组件卸载时自动清理所有定时器 // 组件卸载时自动清理所有定时器
useEffect(() => { useEffect(() => {
return () => clearAllTimers() return () => clearAllTimers()
}, []) }, [clearAllTimers])
/** /**
* setTimeout * setTimeout
@ -65,12 +93,15 @@ export const useTimer = () => {
* cleanup(); * cleanup();
* ``` * ```
*/ */
const setTimeoutTimer = (key: string, ...args: Parameters<typeof setTimeout>) => { const setTimeoutTimer = useCallback(
clearTimeout(timeoutMapRef.current.get(key)) (key: string, ...args: Parameters<typeof setTimeout>) => {
const timer = setTimeout(...args) clearTimeout(timeoutMapRef.current.get(key))
timeoutMapRef.current.set(key, timer) const timer = setTimeout(...args)
return () => clearTimeoutTimer(key) timeoutMapRef.current.set(key, timer)
} return () => clearTimeoutTimer(key)
},
[clearTimeoutTimer]
)
/** /**
* setInterval * setInterval
@ -89,56 +120,31 @@ export const useTimer = () => {
* cleanup(); * cleanup();
* ``` * ```
*/ */
const setIntervalTimer = (key: string, ...args: Parameters<typeof setInterval>) => { const setIntervalTimer = useCallback(
clearInterval(intervalMapRef.current.get(key)) (key: string, ...args: Parameters<typeof setInterval>) => {
const timer = setInterval(...args) clearInterval(intervalMapRef.current.get(key))
intervalMapRef.current.set(key, timer) const timer = setInterval(...args)
return () => clearIntervalTimer(key) intervalMapRef.current.set(key, timer)
} return () => clearIntervalTimer(key)
},
/** [clearIntervalTimer]
* key setTimeout )
* @param key -
*/
const clearTimeoutTimer = (key: string) => {
clearTimeout(timeoutMapRef.current.get(key))
timeoutMapRef.current.delete(key)
}
/**
* key setInterval
* @param key -
*/
const clearIntervalTimer = (key: string) => {
clearInterval(intervalMapRef.current.get(key))
intervalMapRef.current.delete(key)
}
/** /**
* setTimeout * setTimeout
*/ */
const clearAllTimeoutTimers = () => { const clearAllTimeoutTimers = useCallback(() => {
timeoutMapRef.current.forEach((timer) => clearTimeout(timer)) timeoutMapRef.current.forEach((timer) => clearTimeout(timer))
timeoutMapRef.current.clear() timeoutMapRef.current.clear()
} }, [])
/** /**
* setInterval * setInterval
*/ */
const clearAllIntervalTimers = () => { const clearAllIntervalTimers = useCallback(() => {
intervalMapRef.current.forEach((timer) => clearInterval(timer)) intervalMapRef.current.forEach((timer) => clearInterval(timer))
intervalMapRef.current.clear() intervalMapRef.current.clear()
} }, [])
/**
* setTimeout setInterval
*/
const clearAllTimers = () => {
timeoutMapRef.current.forEach((timer) => clearTimeout(timer))
intervalMapRef.current.forEach((timer) => clearInterval(timer))
timeoutMapRef.current.clear()
intervalMapRef.current.clear()
}
return { return {
setTimeoutTimer, setTimeoutTimer,

View File

@ -10,6 +10,7 @@ import { useSettings } from '@renderer/hooks/useSettings'
import { useTimer } from '@renderer/hooks/useTimer' import { useTimer } from '@renderer/hooks/useTimer'
import type { RootState } from '@renderer/store' import type { RootState } from '@renderer/store'
// import { selectCurrentTopicId } from '@renderer/store/newMessage' // import { selectCurrentTopicId } from '@renderer/store/newMessage'
import { scrollIntoView } from '@renderer/utils/dom'
import { Button, Drawer, Tooltip } from 'antd' import { Button, Drawer, Tooltip } from 'antd'
import type { FC } from 'react' import type { FC } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
@ -118,7 +119,8 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
} }
const scrollToMessage = (element: HTMLElement) => { const scrollToMessage = (element: HTMLElement) => {
element.scrollIntoView({ behavior: 'smooth', block: 'start' }) // Use container: 'nearest' to keep scroll within the chat pane (Chromium-only, see #11565, #11567)
scrollIntoView(element, { behavior: 'smooth', block: 'start', container: 'nearest' })
} }
const scrollToTop = () => { const scrollToTop = () => {

View File

@ -15,6 +15,7 @@ import { estimateMessageUsage } from '@renderer/services/TokenService'
import type { Assistant, Topic } from '@renderer/types' import type { Assistant, Topic } from '@renderer/types'
import type { Message, MessageBlock } from '@renderer/types/newMessage' import type { Message, MessageBlock } from '@renderer/types/newMessage'
import { classNames, cn } from '@renderer/utils' import { classNames, cn } from '@renderer/utils'
import { scrollIntoView } from '@renderer/utils/dom'
import { isMessageProcessing } from '@renderer/utils/messageUtils/is' import { isMessageProcessing } from '@renderer/utils/messageUtils/is'
import { Divider } from 'antd' import { Divider } from 'antd'
import type { Dispatch, FC, SetStateAction } from 'react' import type { Dispatch, FC, SetStateAction } from 'react'
@ -79,9 +80,10 @@ const MessageItem: FC<Props> = ({
useEffect(() => { useEffect(() => {
if (isEditing && messageContainerRef.current) { if (isEditing && messageContainerRef.current) {
messageContainerRef.current.scrollIntoView({ scrollIntoView(messageContainerRef.current, {
behavior: 'smooth', behavior: 'smooth',
block: 'center' block: 'center',
container: 'nearest'
}) })
} }
}, [isEditing]) }, [isEditing])
@ -124,7 +126,7 @@ const MessageItem: FC<Props> = ({
const messageHighlightHandler = useCallback( const messageHighlightHandler = useCallback(
(highlight: boolean = true) => { (highlight: boolean = true) => {
if (messageContainerRef.current) { if (messageContainerRef.current) {
messageContainerRef.current.scrollIntoView({ behavior: 'smooth' }) scrollIntoView(messageContainerRef.current, { behavior: 'smooth', block: 'center', container: 'nearest' })
if (highlight) { if (highlight) {
setTimeoutTimer( setTimeoutTimer(
'messageHighlightHandler', 'messageHighlightHandler',

View File

@ -12,6 +12,7 @@ import { newMessagesActions } from '@renderer/store/newMessage'
// import { updateMessageThunk } from '@renderer/store/thunk/messageThunk' // import { updateMessageThunk } from '@renderer/store/thunk/messageThunk'
import type { Message } from '@renderer/types/newMessage' import type { Message } from '@renderer/types/newMessage'
import { isEmoji, removeLeadingEmoji } from '@renderer/utils' import { isEmoji, removeLeadingEmoji } from '@renderer/utils'
import { scrollIntoView } from '@renderer/utils/dom'
import { getMainTextContent } from '@renderer/utils/messageUtils/find' import { getMainTextContent } from '@renderer/utils/messageUtils/find'
import { Avatar } from 'antd' import { Avatar } from 'antd'
import { CircleChevronDown } from 'lucide-react' import { CircleChevronDown } from 'lucide-react'
@ -119,7 +120,7 @@ const MessageAnchorLine: FC<MessageLineProps> = ({ messages }) => {
() => { () => {
const messageElement = document.getElementById(`message-${message.id}`) const messageElement = document.getElementById(`message-${message.id}`)
if (messageElement) { if (messageElement) {
messageElement.scrollIntoView({ behavior: 'auto', block: 'start' }) scrollIntoView(messageElement, { behavior: 'auto', block: 'start', container: 'nearest' })
} }
}, },
100 100
@ -141,7 +142,7 @@ const MessageAnchorLine: FC<MessageLineProps> = ({ messages }) => {
return return
} }
messageElement.scrollIntoView({ behavior: 'smooth', block: 'start' }) scrollIntoView(messageElement, { behavior: 'smooth', block: 'start', container: 'nearest' })
}, },
[setSelectedMessage] [setSelectedMessage]
) )

View File

@ -10,6 +10,7 @@ import type { MultiModelMessageStyle } from '@renderer/store/settings'
import type { Topic } from '@renderer/types' import type { Topic } from '@renderer/types'
import type { Message } from '@renderer/types/newMessage' import type { Message } from '@renderer/types/newMessage'
import { classNames } from '@renderer/utils' import { classNames } from '@renderer/utils'
import { scrollIntoView } from '@renderer/utils/dom'
import { Popover } from 'antd' import { Popover } from 'antd'
import type { ComponentProps } from 'react' import type { ComponentProps } from 'react'
import { memo, useCallback, useEffect, useMemo, useState } from 'react' import { memo, useCallback, useEffect, useMemo, useState } from 'react'
@ -73,7 +74,7 @@ const MessageGroup = ({ messages, topic, registerMessageElement }: Props) => {
() => { () => {
const messageElement = document.getElementById(`message-${message.id}`) const messageElement = document.getElementById(`message-${message.id}`)
if (messageElement) { if (messageElement) {
messageElement.scrollIntoView({ behavior: 'smooth', block: 'start' }) scrollIntoView(messageElement, { behavior: 'smooth', block: 'start', container: 'nearest' })
} }
}, },
200 200
@ -132,7 +133,7 @@ const MessageGroup = ({ messages, topic, registerMessageElement }: Props) => {
setSelectedMessage(message) setSelectedMessage(message)
} else { } else {
// 直接滚动 // 直接滚动
element.scrollIntoView({ behavior: 'smooth', block: 'start' }) scrollIntoView(element, { behavior: 'smooth', block: 'start', container: 'nearest' })
} }
} }
} }

View File

@ -3,6 +3,7 @@ import type { RootState } from '@renderer/store'
import { messageBlocksSelectors } from '@renderer/store/messageBlock' import { messageBlocksSelectors } from '@renderer/store/messageBlock'
import type { Message } from '@renderer/types/newMessage' import type { Message } from '@renderer/types/newMessage'
import { MessageBlockType } from '@renderer/types/newMessage' import { MessageBlockType } from '@renderer/types/newMessage'
import { scrollIntoView } from '@renderer/utils/dom'
import type { FC } from 'react' import type { FC } from 'react'
import React, { useMemo, useRef } from 'react' import React, { useMemo, useRef } from 'react'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
@ -72,10 +73,10 @@ const MessageOutline: FC<MessageOutlineProps> = ({ message }) => {
const parent = messageOutlineContainerRef.current?.parentElement const parent = messageOutlineContainerRef.current?.parentElement
const messageContentContainer = parent?.querySelector('.message-content-container') const messageContentContainer = parent?.querySelector('.message-content-container')
if (messageContentContainer) { if (messageContentContainer) {
const headingElement = messageContentContainer.querySelector(`#${id}`) const headingElement = messageContentContainer.querySelector<HTMLElement>(`#${id}`)
if (headingElement) { if (headingElement) {
const scrollBlock = ['horizontal', 'grid'].includes(message.multiModelMessageStyle ?? '') ? 'nearest' : 'start' const scrollBlock = ['horizontal', 'grid'].includes(message.multiModelMessageStyle ?? '') ? 'nearest' : 'start'
headingElement.scrollIntoView({ behavior: 'smooth', block: scrollBlock }) scrollIntoView(headingElement, { behavior: 'smooth', block: scrollBlock, container: 'nearest' })
} }
} }
} }

View File

@ -1,3 +1,15 @@
import { loggerService } from '@logger'
const logger = loggerService.withContext('utils/dom')
interface ChromiumScrollIntoViewOptions extends ScrollIntoViewOptions {
/**
* @see https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView#container
* @see https://github.com/microsoft/TypeScript/issues/62803
*/
container?: 'all' | 'nearest'
}
/** /**
* Simple wrapper for scrollIntoView with common default options. * Simple wrapper for scrollIntoView with common default options.
* Provides a unified interface with sensible defaults. * Provides a unified interface with sensible defaults.
@ -5,7 +17,12 @@
* @param element - The target element to scroll into view * @param element - The target element to scroll into view
* @param options - Scroll options. If not provided, uses { behavior: 'smooth', block: 'center', inline: 'nearest' } * @param options - Scroll options. If not provided, uses { behavior: 'smooth', block: 'center', inline: 'nearest' }
*/ */
export function scrollIntoView(element: HTMLElement, options?: ScrollIntoViewOptions): void { export function scrollIntoView(element: HTMLElement, options?: ChromiumScrollIntoViewOptions): void {
if (!element) {
logger.warn('[scrollIntoView] Unexpected falsy element. Do nothing as fallback.')
return
}
const defaultOptions: ScrollIntoViewOptions = { const defaultOptions: ScrollIntoViewOptions = {
behavior: 'smooth', behavior: 'smooth',
block: 'center', block: 'center',