import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { UiMessage } from "@/stores/chat/ui-message"; import { ChatArea } from "../chat-area"; vi.mock("../message-bubble", () => ({ MessageBubble: ({ content }: { content: string }) =>
{content}
, })); let scrollHeight = 0; let clientHeight = 0; let resizeObservers: MockResizeObserver[] = []; class MockResizeObserver { readonly callback: ResizeObserverCallback; constructor(callback: ResizeObserverCallback) { this.callback = callback; resizeObservers.push(this); } observe(): void {} disconnect(): void {} trigger(): void { this.callback([], this as unknown as ResizeObserver); } } describe("ChatArea scrolling", () => { let container: HTMLDivElement; let root: Root; beforeEach(() => { (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) .IS_REACT_ACT_ENVIRONMENT = true; scrollHeight = 0; clientHeight = 0; resizeObservers = []; vi.stubGlobal("ResizeObserver", MockResizeObserver); vi.stubGlobal("requestAnimationFrame", vi.fn(() => 1)); vi.stubGlobal("cancelAnimationFrame", vi.fn()); vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockImplementation( function (this: HTMLElement) { return this.getAttribute("aria-label") === "Chat messages" ? scrollHeight : 0; }, ); vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockImplementation( function (this: HTMLElement) { return this.getAttribute("aria-label") === "Chat messages" ? clientHeight : 0; }, ); container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); }); afterEach(() => { act(() => root.unmount()); container.remove(); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); it("lands at the bottom on a normal entry", () => { scrollHeight = 900; clientHeight = 300; renderChatArea(root, [createMessage("history-1")], true); expect(getScrollNode(container).scrollTop).toBe(600); }); it("waits for promotion bootstrap before settling at the final bottom", () => { scrollHeight = 800; clientHeight = 300; const history = [createMessage("history-1")]; renderChatArea(root, history, false); expect(getScrollNode(container).scrollTop).toBe(0); scrollHeight = 1_200; renderChatArea( root, [...history, createMessage("promotion-1")], true, ); expect(getScrollNode(container).scrollTop).toBe(900); }); it("keeps the list bottom-anchored when rendered content grows", () => { scrollHeight = 1_000; clientHeight = 300; renderChatArea(root, [createMessage("history-1")], true); expect(getScrollNode(container).scrollTop).toBe(700); scrollHeight = 1_400; act(() => resizeObservers.forEach((observer) => observer.trigger())); expect(getScrollNode(container).scrollTop).toBe(1_100); }); it("follows new messages when the user remains near the bottom", () => { scrollHeight = 1_000; clientHeight = 300; const history = [createMessage("history-1")]; renderChatArea(root, history, true); const scrollNode = getScrollNode(container); const scrollTo = vi.fn(); Object.defineProperty(scrollNode, "scrollTo", { value: scrollTo }); scrollNode.scrollTop = 650; act(() => scrollNode.dispatchEvent(new Event("scroll", { bubbles: true }))); scrollHeight = 1_400; renderChatArea(root, [...history, createMessage("reply-1")], true); expect(scrollNode.scrollTop).toBe(1_100); expect(scrollTo).not.toHaveBeenCalled(); }); it("preserves position after the user intentionally scrolls upward", () => { scrollHeight = 1_000; clientHeight = 300; const history = [createMessage("history-1")]; renderChatArea(root, history, true); const scrollNode = getScrollNode(container); scrollNode.scrollTop = 100; act(() => scrollNode.dispatchEvent(new Event("scroll", { bubbles: true }))); scrollHeight = 1_400; act(() => resizeObservers.forEach((observer) => observer.trigger())); renderChatArea(root, [...history, createMessage("reply-1")], true); expect(scrollNode.scrollTop).toBe(100); }); it("forces the latest user message into view after the keyboard resizes", () => { scrollHeight = 1_000; clientHeight = 300; const history = [createMessage("history-1")]; renderChatArea(root, history, true, { scrollToBottomSignal: 0 }); const scrollNode = getScrollNode(container); clientHeight = 200; scrollNode.scrollTop = 700; act(() => scrollNode.dispatchEvent(new Event("scroll", { bubbles: true }))); scrollHeight = 1_200; renderChatArea( root, [...history, createUserMessage("sent-1")], true, { scrollToBottomSignal: 1 }, ); expect(scrollNode.scrollTop).toBe(1_000); }); it("scrolls below a promotion after the user sends a message", () => { scrollHeight = 1_000; clientHeight = 300; const history = [createMessage("history-1")]; const promotion = createMessage("promotion-1"); renderChatArea(root, [...history, promotion], true, { scrollToBottomSignal: 0, }); const scrollNode = getScrollNode(container); scrollNode.scrollTop = 100; act(() => scrollNode.dispatchEvent(new Event("scroll", { bubbles: true }))); scrollHeight = 1_500; renderChatArea( root, [...history, createUserMessage("sent-1"), promotion], true, { scrollToBottomSignal: 1 }, ); expect(scrollNode.scrollTop).toBe(1_200); }); it("preserves the visible message after older history is prepended", () => { scrollHeight = 1_000; clientHeight = 300; const onLoadMoreHistory = vi.fn(); const history = [createMessage("history-1")]; renderChatArea(root, history, true, { canLoadMoreHistory: true, onLoadMoreHistory, }); const scrollNode = getScrollNode(container); scrollNode.scrollTop = 0; act(() => scrollNode.dispatchEvent(new Event("scroll", { bubbles: true }))); pull(scrollNode, 180); expect(onLoadMoreHistory).toHaveBeenCalledOnce(); scrollHeight = 1_400; renderChatArea( root, [createMessage("older-1"), ...history], true, { canLoadMoreHistory: true, onLoadMoreHistory, }, ); expect(scrollNode.scrollTop).toBe(400); }); }); function renderChatArea( root: Root, messages: readonly UiMessage[], initialScrollReady: boolean, historyOptions: { canLoadMoreHistory?: boolean; isLoadingMoreHistory?: boolean; onLoadMoreHistory?: () => void; scrollToBottomSignal?: number; } = {}, ): void { act(() => { root.render( , ); }); } function pull(target: HTMLElement, distance: number): void { act(() => { target.dispatchEvent(createTouchEvent("touchstart", 0)); target.dispatchEvent(createTouchEvent("touchmove", distance)); target.dispatchEvent(createTouchEvent("touchend", distance, true)); }); } function createTouchEvent( type: string, clientY: number, ended = false, ): Event { const event = new Event(type, { bubbles: true, cancelable: true }); Object.defineProperty(event, "touches", { value: ended ? [] : [{ identifier: 1, clientY }], }); return event; } function createMessage(id: string): UiMessage { return { id, content: `Message ${id}`, isFromAI: true, date: "2026-07-15", }; } function createUserMessage(id: string): UiMessage { return { id, content: `Message ${id}`, isFromAI: false, date: "2026-07-15", }; } function getScrollNode(container: HTMLElement): HTMLElement { const node = container.querySelector( '[aria-label="Chat messages"]', ); if (!node) throw new Error("Chat scroll container was not rendered"); return node; }