UNPKG

@assistant-ui/react

Version:

Open-source TypeScript/React library for building production-grade AI chat experiences

571 lines (474 loc) • 17.9 kB
// @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { mountTopAnchorReserve, type TopAnchorStore, } from "./mountTopAnchorReserve"; class ResizeObserverMock { static callbacks: (() => void)[] = []; constructor(callback: () => void) { ResizeObserverMock.callbacks.push(callback); } observe = vi.fn(); disconnect = vi.fn(); } class MutationObserverMock { observe = vi.fn(); disconnect = vi.fn(); } const defineReadonlyNumber = ( element: HTMLElement, key: "clientHeight" | "scrollHeight" | "offsetHeight" | "offsetTop", value: number, ) => { Object.defineProperty(element, key, { configurable: true, value }); }; const makeStore = (state: ReturnType<TopAnchorStore["getState"]>) => { const listeners = new Set<() => void>(); return { store: { getState: () => state, subscribe: (listener: () => void) => { listeners.add(listener); return () => listeners.delete(listener); }, } satisfies TopAnchorStore, setState: (nextState: ReturnType<TopAnchorStore["getState"]>) => { state = nextState; for (const listener of listeners) listener(); }, }; }; const numericClamp = { tallerThan: 160, visibleHeight: 96 }; const activeTopAnchorTurn = { anchorId: "user-1", targetId: "assistant-1" }; describe("mountTopAnchorReserve", () => { beforeEach(() => { vi.useFakeTimers(); ResizeObserverMock.callbacks = []; vi.stubGlobal("ResizeObserver", ResizeObserverMock); vi.stubGlobal("MutationObserver", MutationObserverMock); }); afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); document.body.replaceChildren(); }); it("adds enough stable reserve after the active assistant turn to make the top anchor reachable", () => { const viewport = document.createElement("div"); const anchor = document.createElement("div"); const target = document.createElement("div"); const reserveHost = document.createElement("div"); reserveHost.append(target); document.body.append(reserveHost); defineReadonlyNumber(viewport, "offsetTop", 0); defineReadonlyNumber(viewport, "clientHeight", 400); defineReadonlyNumber(viewport, "scrollHeight", 560); defineReadonlyNumber(anchor, "offsetTop", 220); defineReadonlyNumber(anchor, "offsetHeight", 64); viewport.scrollTo = vi.fn(); const { store } = makeStore({ turnAnchor: "top", element: { viewport, anchor, target }, targetConfig: numericClamp, topAnchorTurn: activeTopAnchorTurn, }); mountTopAnchorReserve(store); vi.runOnlyPendingTimers(); const reserve = reserveHost.querySelector( "[data-aui-top-anchor-reserve]", ) as HTMLElement; expect(reserve).not.toBe(null); expect(reserve.previousElementSibling).toBe(target); expect(reserve.style.height).toBe("60px"); }); it("preserves the reserve across a transient between-turns anchor gap", () => { const viewport = document.createElement("div"); const anchor = document.createElement("div"); const target = document.createElement("div"); const reserveHost = document.createElement("div"); reserveHost.append(target); document.body.append(reserveHost); defineReadonlyNumber(viewport, "offsetTop", 0); defineReadonlyNumber(viewport, "clientHeight", 400); defineReadonlyNumber(viewport, "scrollHeight", 560); defineReadonlyNumber(anchor, "offsetTop", 220); defineReadonlyNumber(anchor, "offsetHeight", 64); viewport.scrollTo = vi.fn(); const { store, setState } = makeStore({ turnAnchor: "top", element: { viewport, anchor, target }, targetConfig: numericClamp, topAnchorTurn: activeTopAnchorTurn, }); const unmount = mountTopAnchorReserve(store); vi.runOnlyPendingTimers(); const reserve = reserveHost.querySelector( "[data-aui-top-anchor-reserve]", ) as HTMLElement; const optimisticUserMessage = document.createElement("div"); reserveHost.append(optimisticUserMessage); const appendSpy = vi.spyOn(reserveHost, "append"); setState({ turnAnchor: "top", element: { viewport, anchor: null, target: null }, targetConfig: null, topAnchorTurn: activeTopAnchorTurn, }); vi.runOnlyPendingTimers(); expect(reserve.isConnected).toBe(true); expect(reserve.style.height).toBe("60px"); expect(reserve.previousElementSibling).toBe(optimisticUserMessage); expect(appendSpy).toHaveBeenCalledTimes(1); setState({ turnAnchor: "top", element: { viewport, anchor: null, target: null }, targetConfig: null, topAnchorTurn: activeTopAnchorTurn, }); vi.runOnlyPendingTimers(); expect(appendSpy).toHaveBeenCalledTimes(1); const nextAnchor = document.createElement("div"); const nextTarget = document.createElement("div"); reserveHost.append(nextTarget); defineReadonlyNumber(nextAnchor, "offsetTop", 236); defineReadonlyNumber(nextAnchor, "offsetHeight", 64); setState({ turnAnchor: "top", element: { viewport, anchor: nextAnchor, target: nextTarget }, targetConfig: numericClamp, topAnchorTurn: activeTopAnchorTurn, }); vi.runOnlyPendingTimers(); expect(reserve.previousElementSibling).toBe(nextTarget); unmount(); expect(reserve.isConnected).toBe(false); }); it("removes the reserve when the active turn is no longer valid", () => { const viewport = document.createElement("div"); const anchor = document.createElement("div"); const target = document.createElement("div"); const reserveHost = document.createElement("div"); reserveHost.append(target); document.body.append(reserveHost); defineReadonlyNumber(viewport, "offsetTop", 0); defineReadonlyNumber(viewport, "clientHeight", 400); defineReadonlyNumber(viewport, "scrollHeight", 560); defineReadonlyNumber(anchor, "offsetTop", 220); defineReadonlyNumber(anchor, "offsetHeight", 64); viewport.scrollTo = vi.fn(); const { store, setState } = makeStore({ turnAnchor: "top", element: { viewport, anchor, target }, targetConfig: numericClamp, topAnchorTurn: activeTopAnchorTurn, }); mountTopAnchorReserve(store); vi.runOnlyPendingTimers(); const reserve = reserveHost.querySelector( "[data-aui-top-anchor-reserve]", ) as HTMLElement; setState({ turnAnchor: "top", element: { viewport, anchor: null, target: null }, targetConfig: null, topAnchorTurn: null, }); vi.runOnlyPendingTimers(); expect(reserve.isConnected).toBe(false); expect(reserve.style.height).toBe("0px"); }); it("removes the reserve when only the registered anchor unmounts", () => { const viewport = document.createElement("div"); const anchor = document.createElement("div"); const target = document.createElement("div"); const reserveHost = document.createElement("div"); reserveHost.append(target); document.body.append(reserveHost); defineReadonlyNumber(viewport, "offsetTop", 0); defineReadonlyNumber(viewport, "clientHeight", 400); defineReadonlyNumber(viewport, "scrollHeight", 560); defineReadonlyNumber(anchor, "offsetTop", 220); defineReadonlyNumber(anchor, "offsetHeight", 64); viewport.scrollTo = vi.fn(); const { store, setState } = makeStore({ turnAnchor: "top", element: { viewport, anchor, target }, targetConfig: numericClamp, topAnchorTurn: activeTopAnchorTurn, }); mountTopAnchorReserve(store); vi.runOnlyPendingTimers(); const reserve = reserveHost.querySelector( "[data-aui-top-anchor-reserve]", ) as HTMLElement; setState({ turnAnchor: "top", element: { viewport, anchor: null, target }, targetConfig: numericClamp, topAnchorTurn: activeTopAnchorTurn, }); vi.runOnlyPendingTimers(); expect(reserve.isConnected).toBe(false); expect(reserve.style.height).toBe("0px"); }); it("removes the reserve when the viewport is unavailable", () => { const viewport = document.createElement("div"); const anchor = document.createElement("div"); const target = document.createElement("div"); const reserveHost = document.createElement("div"); reserveHost.append(target); document.body.append(reserveHost); defineReadonlyNumber(viewport, "offsetTop", 0); defineReadonlyNumber(viewport, "clientHeight", 400); defineReadonlyNumber(viewport, "scrollHeight", 560); defineReadonlyNumber(anchor, "offsetTop", 220); defineReadonlyNumber(anchor, "offsetHeight", 64); viewport.scrollTo = vi.fn(); const { store, setState } = makeStore({ turnAnchor: "top", element: { viewport, anchor, target }, targetConfig: numericClamp, topAnchorTurn: activeTopAnchorTurn, }); mountTopAnchorReserve(store); vi.runOnlyPendingTimers(); const reserve = reserveHost.querySelector( "[data-aui-top-anchor-reserve]", ) as HTMLElement; setState({ turnAnchor: "top", element: { viewport: null, anchor: null, target: null }, targetConfig: null, topAnchorTurn: null, }); vi.runOnlyPendingTimers(); expect(reserve.isConnected).toBe(false); expect(reserve.style.height).toBe("0px"); }); it("removes the reserve when top anchoring is disabled", () => { const viewport = document.createElement("div"); const anchor = document.createElement("div"); const target = document.createElement("div"); const reserveHost = document.createElement("div"); reserveHost.append(target); document.body.append(reserveHost); defineReadonlyNumber(viewport, "offsetTop", 0); defineReadonlyNumber(viewport, "clientHeight", 400); defineReadonlyNumber(viewport, "scrollHeight", 560); defineReadonlyNumber(anchor, "offsetTop", 220); defineReadonlyNumber(anchor, "offsetHeight", 64); viewport.scrollTo = vi.fn(); const { store, setState } = makeStore({ turnAnchor: "top", element: { viewport, anchor, target }, targetConfig: numericClamp, topAnchorTurn: activeTopAnchorTurn, }); mountTopAnchorReserve(store); vi.runOnlyPendingTimers(); const reserve = reserveHost.querySelector( "[data-aui-top-anchor-reserve]", ) as HTMLElement; setState({ turnAnchor: "bottom", element: { viewport, anchor: null, target: null }, targetConfig: null, topAnchorTurn: null, }); vi.runOnlyPendingTimers(); expect(reserve.isConnected).toBe(false); expect(reserve.style.height).toBe("0px"); }); const mountPinnedViewport = () => { const viewport = document.createElement("div"); const anchor = document.createElement("div"); const target = document.createElement("div"); const reserveHost = document.createElement("div"); reserveHost.append(target); document.body.append(reserveHost); let naturalScrollHeight = 560; defineReadonlyNumber(viewport, "offsetTop", 0); defineReadonlyNumber(viewport, "clientHeight", 400); Object.defineProperty(viewport, "scrollHeight", { configurable: true, get: () => { const reserve = document.querySelector<HTMLElement>( "[data-aui-top-anchor-reserve]", ); return ( naturalScrollHeight + Number.parseFloat(reserve?.style.height || "0") ); }, }); defineReadonlyNumber(anchor, "offsetTop", 220); defineReadonlyNumber(anchor, "offsetHeight", 64); anchor.dataset.messageId = "msg-1"; viewport.scrollTo = vi.fn(); const { store, setState } = makeStore({ turnAnchor: "top", element: { viewport, anchor, target }, targetConfig: numericClamp, topAnchorTurn: activeTopAnchorTurn, }); const unmount = mountTopAnchorReserve(store); vi.runOnlyPendingTimers(); const reserve = reserveHost.querySelector<HTMLElement>( "[data-aui-top-anchor-reserve]", )!; Object.defineProperty(reserve, "offsetHeight", { configurable: true, get: () => Number.parseFloat(reserve.style.height || "0"), }); vi.runOnlyPendingTimers(); expect(viewport.scrollTo).toHaveBeenCalledTimes(1); expect(viewport.scrollTo).toHaveBeenLastCalledWith({ top: 220, behavior: "smooth", }); viewport.scrollTop = 220; viewport.dispatchEvent(new Event("scroll")); return { viewport, anchor, target, store, setState, unmount, notifyLayout: () => ResizeObserverMock.callbacks.at(-1)!(), setNaturalScrollHeight: (height: number) => { naturalScrollHeight = height; }, }; }; it("restores the pre-clamp position when the old offset becomes unreachable", () => { const { viewport, setNaturalScrollHeight } = mountPinnedViewport(); setNaturalScrollHeight(400); viewport.scrollTop = 60; viewport.dispatchEvent(new Event("scroll")); setNaturalScrollHeight(560); vi.runOnlyPendingTimers(); expect(viewport.scrollTo).toHaveBeenCalledTimes(2); expect(viewport.scrollTo).toHaveBeenLastCalledWith({ top: 220, behavior: "instant", }); }); it("does not undo a reachable scrollIntoView movement", () => { const { viewport, target, notifyLayout } = mountPinnedViewport(); target.scrollIntoView = vi.fn(() => { viewport.scrollTop = 100; viewport.dispatchEvent(new Event("scroll")); }); target.scrollIntoView(); notifyLayout(); vi.runOnlyPendingTimers(); expect(viewport.scrollTo).toHaveBeenCalledTimes(1); }); it("does not undo reachable focus scrolling", () => { const { viewport, target, notifyLayout } = mountPinnedViewport(); const input = document.createElement("input"); target.append(input); input.focus(); viewport.scrollTop = 100; viewport.dispatchEvent(new Event("scroll")); notifyLayout(); vi.runOnlyPendingTimers(); expect(viewport.scrollTo).toHaveBeenCalledTimes(1); }); it("does not restore an out-of-range movement that did not land at the maximum", () => { const { viewport, setNaturalScrollHeight, notifyLayout } = mountPinnedViewport(); setNaturalScrollHeight(400); viewport.scrollTop = 40; viewport.dispatchEvent(new Event("scroll")); setNaturalScrollHeight(560); notifyLayout(); vi.runOnlyPendingTimers(); expect(viewport.scrollTo).toHaveBeenCalledTimes(1); }); it("restores at most once per anchor turn", () => { const { viewport, setNaturalScrollHeight, notifyLayout } = mountPinnedViewport(); setNaturalScrollHeight(400); viewport.scrollTop = 60; viewport.dispatchEvent(new Event("scroll")); setNaturalScrollHeight(560); vi.runOnlyPendingTimers(); expect(viewport.scrollTo).toHaveBeenCalledTimes(2); viewport.scrollTop = 220; viewport.dispatchEvent(new Event("scroll")); setNaturalScrollHeight(400); viewport.scrollTop = 60; viewport.dispatchEvent(new Event("scroll")); setNaturalScrollHeight(560); notifyLayout(); vi.runOnlyPendingTimers(); expect(viewport.scrollTo).toHaveBeenCalledTimes(2); }); it("drops a pending restore across an anchor-gap thread transition", () => { const { viewport, setState, setNaturalScrollHeight } = mountPinnedViewport(); setNaturalScrollHeight(400); viewport.scrollTop = 60; viewport.dispatchEvent(new Event("scroll")); setState({ turnAnchor: "top", element: { viewport, anchor: null, target: null }, targetConfig: null, topAnchorTurn: activeTopAnchorTurn, }); vi.runOnlyPendingTimers(); const nextAnchor = document.createElement("div"); const nextTarget = document.createElement("div"); document.body.append(nextTarget); defineReadonlyNumber(nextAnchor, "offsetTop", 480); defineReadonlyNumber(nextAnchor, "offsetHeight", 64); nextAnchor.dataset.messageId = "msg-2"; setState({ turnAnchor: "top", element: { viewport, anchor: nextAnchor, target: nextTarget }, targetConfig: numericClamp, topAnchorTurn: { anchorId: "user-2", targetId: "assistant-2" }, }); setNaturalScrollHeight(560); vi.runOnlyPendingTimers(); vi.runOnlyPendingTimers(); expect(viewport.scrollTo).toHaveBeenCalledTimes(2); expect(viewport.scrollTo).toHaveBeenLastCalledWith({ top: 480, behavior: "smooth", }); }); it("does not repeat the smooth top-anchor scroll for the same message", () => { const viewport = document.createElement("div"); const anchor = document.createElement("div"); const target = document.createElement("div"); document.body.append(target); defineReadonlyNumber(viewport, "offsetTop", 0); defineReadonlyNumber(viewport, "clientHeight", 400); defineReadonlyNumber(viewport, "scrollHeight", 560); defineReadonlyNumber(anchor, "offsetTop", 220); defineReadonlyNumber(anchor, "offsetHeight", 64); anchor.dataset.messageId = "msg-1"; viewport.scrollTo = vi.fn(); const { store, setState } = makeStore({ turnAnchor: "top", element: { viewport, anchor, target }, targetConfig: numericClamp, topAnchorTurn: activeTopAnchorTurn, }); mountTopAnchorReserve(store); vi.runOnlyPendingTimers(); expect(viewport.scrollTo).not.toHaveBeenCalled(); vi.runOnlyPendingTimers(); expect(viewport.scrollTo).toHaveBeenCalledTimes(1); setState({ turnAnchor: "top", element: { viewport, anchor, target }, targetConfig: numericClamp, topAnchorTurn: activeTopAnchorTurn, }); vi.runOnlyPendingTimers(); expect(viewport.scrollTo).toHaveBeenCalledTimes(1); }); });