@assistant-ui/react
Version:
Open-source TypeScript/React library for building production-grade AI chat experiences
571 lines (474 loc) • 17.9 kB
text/typescript
// @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);
});
});