diff --git a/src/components/MessageList/hooks/MessageList/useScrollLocationLogic.tsx b/src/components/MessageList/hooks/MessageList/useScrollLocationLogic.tsx index 0a02dd5d06..51abbb6124 100644 --- a/src/components/MessageList/hooks/MessageList/useScrollLocationLogic.tsx +++ b/src/components/MessageList/hooks/MessageList/useScrollLocationLogic.tsx @@ -32,6 +32,17 @@ export const useScrollLocationLogic = (params: UseScrollLocationLogicParams) => const closeToTop = useRef(false); const initialDataAutoscrollDoneRef = useRef(false); + // `hasMoreNewer` turning false means the loaded window just reached the live head — typically + // because the user scrolled down out of a window they had jumped to (a search result deep in + // history) and the page bridging the gap arrived. + // + // The viewport must not move for that. The user is reading where they jumped to, and the merged + // page lands *below* them; treating the merge like a mount (or like a newly received message at + // the head) teleports them to the newest message. Both autoscroll paths below are suppressed for + // this one render, and the ref is advanced afterwards so only the transition itself counts. + const previousHasMoreNewer = useRef(hasMoreNewer); + const justReachedLatestMessageSet = previousHasMoreNewer.current && !hasMoreNewer; + // `behavior` is optional so callers opt into animation: the initial-mount and streaming // "keep pinned to bottom" autoscroll below call it with no argument and stay instant (which is // what keeps pagination position-preservation correct), while an interactive scroll-to-latest @@ -54,7 +65,10 @@ export const useScrollLocationLogic = (params: UseScrollLocationLogicParams) => useLayoutEffect(() => { if (listElement) { setWrapperRect(listElement.getBoundingClientRect()); - scrollToBottom(); + // `hasMoreNewer` is a dependency so a list that mounts before its first page resolves still + // autoscrolls once it does. That makes this run on *every* change of it, including the merge + // into the live head, which must leave the viewport alone. + if (!justReachedLatestMessageSet) scrollToBottom(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [listElement, hasMoreNewer]); @@ -83,6 +97,7 @@ export const useScrollLocationLogic = (params: UseScrollLocationLogicParams) => // height-delta compensation (no precise anchor restoration). Reconcile the two scroll // paths if precise anchor preservation on older-page loads is required. captureAnchor: () => null, + justReachedLatestMessageSet, loadMoreScrollThreshold, messages, onScrollBy: (scrollBy) => { @@ -106,6 +121,12 @@ export const useScrollLocationLogic = (params: UseScrollLocationLogicParams) => suppressAutoscroll, }); + // Declared after the two autoscroll layout effects and the scroll manager's own, so all three + // observe the transition before it is consumed. + useLayoutEffect(() => { + previousHasMoreNewer.current = hasMoreNewer; + }, [hasMoreNewer]); + const onScroll = useCallback( (event: React.UIEvent) => { const element = event.target as HTMLElement; diff --git a/src/components/MessageList/hooks/__tests__/useScrollLocationLogic.test.tsx b/src/components/MessageList/hooks/__tests__/useScrollLocationLogic.test.tsx new file mode 100644 index 0000000000..e1f8bac6fb --- /dev/null +++ b/src/components/MessageList/hooks/__tests__/useScrollLocationLogic.test.tsx @@ -0,0 +1,116 @@ +import React from 'react'; +import { act, renderHook } from '@testing-library/react'; +import { fromPartial } from '@total-typescript/shoehorn'; + +import { useScrollLocationLogic } from '../MessageList/useScrollLocationLogic'; +import { ChatProvider } from '../../../../context/ChatContext'; +import { getTestClientWithUser, mockChatContext } from '../../../../mock-builders'; + +import type { LocalMessage, StreamChat } from 'stream-chat'; + +/** + * Regression cover for the viewport jumping to the newest message when a window the user had + * jumped to (a search result deep in history) merges into the live head. + * + * The merge shows up here as `hasMoreNewer` flipping true -> false while messages are appended + * below the loaded window. The user is reading where they jumped; nothing about that transition + * asks for the viewport to move. + */ + +let client: StreamChat; + +const generateMessages = (length: number, offset = 0) => + Array.from({ length }, (_, index) => + fromPartial({ id: `${index + offset}`, user: { id: 'someone-else' } }), + ); + +/** A scroll container sitting at the bottom of its currently loaded window. */ +const createListElement = () => { + const element = { + clientHeight: 600, + getBoundingClientRect: () => fromPartial({ height: 600, width: 400 }), + offsetHeight: 600, + scrollBy: vi.fn(), + scrollHeight: 16000, + scrollTo: vi.fn(), + scrollTop: 15400, + }; + return element as unknown as HTMLElement & { scrollTo: ReturnType }; +}; + +const renderScrollLogic = ({ + hasMoreNewer, + listElement, + messages, +}: { + hasMoreNewer: boolean; + listElement: HTMLElement; + messages: LocalMessage[]; +}) => + renderHook( + (props: { hasMoreNewer: boolean; messages: LocalMessage[] }) => + useScrollLocationLogic({ + hasMoreNewer: props.hasMoreNewer, + listElement, + loadMoreScrollThreshold: 100, + messages: props.messages, + scrolledUpThreshold: 200, + }), + { + initialProps: { hasMoreNewer, messages }, + wrapper: ({ children }: React.PropsWithChildren) => ( + {children} + ), + }, + ); + +describe('useScrollLocationLogic', () => { + beforeEach(async () => { + client = await getTestClientWithUser({ id: 'alice' }); + }); + + it('does not scroll to the newest message when a jumped-to window merges into the live head', () => { + const listElement = createListElement(); + const loadedWindow = generateMessages(25); + + const { rerender, result } = renderScrollLogic({ + hasMoreNewer: true, + listElement, + messages: loadedWindow, + }); + // The list reports its scroll position through onScroll; the manager reads it from there. + act(() => result.current.onScroll(fromPartial({ target: listElement }))); + listElement.scrollTo.mockClear(); + + // The newer page arrives below the loaded window and closes the gap to the live head. + listElement.scrollHeight = 28500; + rerender({ + hasMoreNewer: false, + messages: loadedWindow.concat(generateMessages(25, 25)), + }); + + expect(listElement.scrollTo).not.toHaveBeenCalled(); + }); + + it('still scrolls to the newest message when messages arrive at the live head', () => { + const listElement = createListElement(); + const loadedWindow = generateMessages(25); + + const { rerender, result } = renderScrollLogic({ + hasMoreNewer: false, + listElement, + messages: loadedWindow, + }); + act(() => result.current.onScroll(fromPartial({ target: listElement }))); + listElement.scrollTo.mockClear(); + + // Already at the head and at the bottom: a newly received message should pin the view to it. + listElement.scrollHeight = 17000; + rerender({ + hasMoreNewer: false, + messages: loadedWindow.concat(generateMessages(1, 25)), + }); + + expect(listElement.scrollTo).toHaveBeenCalled(); + }); +});