From fdbfb14f62fc5abee82b89b16c725c0e7febc392 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Wed, 8 Jul 2026 19:52:38 +0300 Subject: [PATCH] More robust/intent based chat scroll to bottom logic --- app/features/chat/chat-hooks.ts | 129 +++++++++++++---- .../chat/components/Chat.browser.test.tsx | 134 ++++++++++++++++++ app/features/chat/components/Chat.tsx | 9 +- 3 files changed, 240 insertions(+), 32 deletions(-) diff --git a/app/features/chat/chat-hooks.ts b/app/features/chat/chat-hooks.ts index 24d98d57a..a0b4bb31f 100644 --- a/app/features/chat/chat-hooks.ts +++ b/app/features/chat/chat-hooks.ts @@ -5,62 +5,135 @@ import { useChatContext } from "./useChatContext"; // increasing this = scrolling happens even when scrolled more upwards const THRESHOLD = 100; +// how long after wheel/touch/keyboard input a scroll event still counts as user-initiated +const USER_SCROLL_INTENT_MS = 150; export function useChatAutoScroll( messages: ChatMessage[], ref: React.RefObject, ) { const user = useUser(); - const [firstLoadHandled, setFirstLoadHandled] = React.useState(false); const [unseenMessages, setUnseenMessages] = React.useState(false); + const pinnedToBottomRef = React.useRef(true); + const lastUserScrollIntentRef = React.useRef(Number.NEGATIVE_INFINITY); + const isPointerDownRef = React.useRef(false); + const lastStableScrollTopRef = React.useRef(0); + + const scrollToBottom = React.useCallback(() => { + const messagesContainer = ref.current; + if (!messagesContainer) return; + + pinnedToBottomRef.current = true; + messagesContainer.scrollTop = messagesContainer.scrollHeight; + setUnseenMessages(false); + }, [ref]); React.useEffect(() => { const messagesContainer = ref.current!; - const isScrolledToBottom = - Math.abs( - messagesContainer.scrollHeight - - messagesContainer.clientHeight - - messagesContainer.scrollTop, - ) <= THRESHOLD; - const latestMessageIsOwn = - messages[messages.length - 1]?.userId === user?.id; - // lets wait for messages to load first - if (!firstLoadHandled && messages.length === 0) return; - - if (isScrolledToBottom || latestMessageIsOwn || !firstLoadHandled) { - setFirstLoadHandled(true); - messagesContainer.scrollTop = messagesContainer.scrollHeight; - } else if (!isScrolledToBottom) { - setUnseenMessages(true); + function markUserScrollIntent() { + lastUserScrollIntentRef.current = performance.now(); + } + function handlePointerDown() { + isPointerDownRef.current = true; + } + function handlePointerUp() { + isPointerDownRef.current = false; } - }, [messages, ref, user, firstLoadHandled]); - - React.useEffect(() => { - const messagesContainer = ref.current!; function handleScroll() { - if ( + const isUserScroll = + isPointerDownRef.current || + performance.now() - lastUserScrollIntentRef.current < + USER_SCROLL_INTENT_MS; + const isScrolledToBottom = messagesContainer.scrollTop + messagesContainer.clientHeight >= - messagesContainer.scrollHeight - THRESHOLD - ) { + messagesContainer.scrollHeight - THRESHOLD; + + // react-aria's Virtualizer resets the scroll position to the top + // whenever the message collection changes; undo those resets so + // they neither unpin the auto scroll nor yank the user out of the + // history they were reading + if (!isUserScroll) { + if (pinnedToBottomRef.current && !isScrolledToBottom) { + messagesContainer.scrollTop = messagesContainer.scrollHeight; + return; + } + if ( + !pinnedToBottomRef.current && + messagesContainer.scrollTop === 0 && + lastStableScrollTopRef.current > 0 + ) { + messagesContainer.scrollTop = lastStableScrollTopRef.current; + return; + } + } + + pinnedToBottomRef.current = isScrolledToBottom; + lastStableScrollTopRef.current = messagesContainer.scrollTop; + if (isScrolledToBottom) { setUnseenMessages(false); } } + messagesContainer.addEventListener("wheel", markUserScrollIntent, { + passive: true, + }); + messagesContainer.addEventListener("touchmove", markUserScrollIntent, { + passive: true, + }); + messagesContainer.addEventListener("keydown", markUserScrollIntent); + messagesContainer.addEventListener("pointerdown", handlePointerDown); + window.addEventListener("pointerup", handlePointerUp); messagesContainer.addEventListener("scroll", handleScroll); return () => { + messagesContainer.removeEventListener("wheel", markUserScrollIntent); + messagesContainer.removeEventListener("touchmove", markUserScrollIntent); + messagesContainer.removeEventListener("keydown", markUserScrollIntent); + messagesContainer.removeEventListener("pointerdown", handlePointerDown); + window.removeEventListener("pointerup", handlePointerUp); messagesContainer.removeEventListener("scroll", handleScroll); }; }, [ref]); - const scrollToBottom = () => { - ref.current!.scrollTop = ref.current!.scrollHeight; - }; + const hasMessages = messages.length > 0; + + // the virtualizer resizes the scrollable content asynchronously as it + // measures rows, without scroll events firing, so keep the view glued to + // the bottom whenever the content height changes while pinned there + React.useEffect(() => { + if (!hasMessages) return; + + const messagesContainer = ref.current!; + const scrollContent = messagesContainer.firstElementChild; + if (!scrollContent) return; + + const observer = new ResizeObserver(() => { + if (pinnedToBottomRef.current) { + messagesContainer.scrollTop = messagesContainer.scrollHeight; + } + }); + observer.observe(scrollContent); + + return () => observer.disconnect(); + }, [ref, hasMessages]); + + React.useEffect(() => { + if (messages.length === 0) return; + + const latestMessageIsOwn = + user != null && messages[messages.length - 1]?.userId === user.id; + + if (latestMessageIsOwn || pinnedToBottomRef.current) { + scrollToBottom(); + } else { + setUnseenMessages(true); + } + }, [messages, user, scrollToBottom]); const reset = () => { - setFirstLoadHandled(false); + pinnedToBottomRef.current = true; setUnseenMessages(false); }; diff --git a/app/features/chat/components/Chat.browser.test.tsx b/app/features/chat/components/Chat.browser.test.tsx index f077e1636..377f75416 100644 --- a/app/features/chat/components/Chat.browser.test.tsx +++ b/app/features/chat/components/Chat.browser.test.tsx @@ -1,3 +1,4 @@ +import * as React from "react"; import { createMemoryRouter, RouterProvider } from "react-router"; import { describe, expect, test, vi } from "vitest"; import { render } from "vitest-browser-react"; @@ -65,6 +66,48 @@ function renderChat( return render(); } +async function renderChatWithControls(initialMessages: ChatMessage[]) { + const controls = { + addMessage: (_msg: ChatMessage) => {}, + }; + + function ChatHarness() { + const [messages, setMessages] = React.useState(initialMessages); + controls.addMessage = (msg) => setMessages((prev) => [...prev, msg]); + + const chat: ChatAdapter = { + messages, + send: () => {}, + currentRoom: "room", + setCurrentRoom: () => {}, + readyState: "CONNECTED", + unseenMessages: new Map(), + }; + + return ( +
+ +
+ ); + } + + const router = createMemoryRouter([{ path: "/", element: }], { + initialEntries: ["/"], + }); + + return { screen: await render(), controls }; +} + +function manyMessages(count: number) { + return Array.from({ length: count }, (_, i) => + createMessage({ id: String(i + 1), contents: `Message ${i + 1}` }), + ); +} + +function isScrolledToBottom(element: HTMLElement) { + return element.scrollTop + element.clientHeight >= element.scrollHeight - 2; +} + describe("Chat", () => { test("renders messages inside a virtualized listbox", async () => { const screen = await renderChat([ @@ -138,4 +181,95 @@ describe("Chat", () => { await expect.element(screen.getByText("Ghost message")).toBeInTheDocument(); await expect.element(screen.getByText("Unknown")).toBeInTheDocument(); }); + + test("scrolls to the bottom on initial load", async () => { + const { screen } = await renderChatWithControls(manyMessages(50)); + + const listbox = screen.getByRole("listbox"); + await expect.element(listbox).toBeInTheDocument(); + + await vi.waitFor(() => { + const element = listbox.element() as HTMLElement; + expect(element.scrollHeight).toBeGreaterThan(element.clientHeight); + expect(isScrolledToBottom(element)).toBe(true); + }); + }); + + test("auto scrolls when a new message arrives while at the bottom", async () => { + const { screen, controls } = await renderChatWithControls(manyMessages(50)); + + const listbox = screen.getByRole("listbox"); + await vi.waitFor(() => { + expect(isScrolledToBottom(listbox.element() as HTMLElement)).toBe(true); + }); + + controls.addMessage( + createMessage({ + id: "new", + contents: + "A brand new message that is long enough to wrap onto multiple lines in the chat window", + }), + ); + + await expect + .element(screen.getByText(/A brand new message/)) + .toBeInTheDocument(); + await vi.waitFor(() => { + expect(isScrolledToBottom(listbox.element() as HTMLElement)).toBe(true); + }); + }); + + test("does not auto scroll when scrolled up, shows the new messages button instead", async () => { + const { screen, controls } = await renderChatWithControls(manyMessages(50)); + + const listbox = screen.getByRole("listbox"); + await vi.waitFor(() => { + expect(isScrolledToBottom(listbox.element() as HTMLElement)).toBe(true); + }); + + const element = listbox.element() as HTMLElement; + element.dispatchEvent(new WheelEvent("wheel", { deltaY: -100 })); + element.scrollTop = 0; + element.dispatchEvent(new Event("scroll")); + await vi.waitFor(() => { + expect(element.scrollTop).toBe(0); + }); + + controls.addMessage(createMessage({ id: "new", contents: "While away" })); + + await expect.element(screen.getByText("New messages")).toBeInTheDocument(); + expect(isScrolledToBottom(element)).toBe(false); + + await screen.getByText("New messages").click(); + + await vi.waitFor(() => { + expect(isScrolledToBottom(element)).toBe(true); + }); + await expect + .element(screen.getByText("New messages")) + .not.toBeInTheDocument(); + }); + + test("keeps the reading position when a new message arrives while scrolled up", async () => { + const { screen, controls } = await renderChatWithControls(manyMessages(50)); + + const listbox = screen.getByRole("listbox"); + await vi.waitFor(() => { + expect(isScrolledToBottom(listbox.element() as HTMLElement)).toBe(true); + }); + + const element = listbox.element() as HTMLElement; + const readingPosition = Math.floor(element.scrollHeight / 2); + element.dispatchEvent(new WheelEvent("wheel", { deltaY: -100 })); + element.scrollTop = readingPosition; + element.dispatchEvent(new Event("scroll")); + + await new Promise((resolve) => setTimeout(resolve, 300)); + + controls.addMessage(createMessage({ id: "new", contents: "While away" })); + + await expect.element(screen.getByText("New messages")).toBeInTheDocument(); + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(element.scrollTop).toBe(readingPosition); + }); }); diff --git a/app/features/chat/components/Chat.tsx b/app/features/chat/components/Chat.tsx index fd30bc585..74d8c5ef4 100644 --- a/app/features/chat/components/Chat.tsx +++ b/app/features/chat/components/Chat.tsx @@ -22,6 +22,10 @@ import styles from "./Chat.module.css"; const MESSAGE_GAP = 8; const ESTIMATED_MESSAGE_HEIGHT = 44; +const VIRTUALIZER_LAYOUT_OPTIONS = { + gap: MESSAGE_GAP, + estimatedRowSize: ESTIMATED_MESSAGE_HEIGHT, +}; export interface ChatAdapter { messages: ChatMessage[]; @@ -165,10 +169,7 @@ export function Chat({