// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useWindowAutoFollow } from "./useWindowAutoFollow";
import { TaskChatScrollNavigation } from "./scroll-navigation";
function Host({ contentKey, enabled }: { contentKey: unknown; enabled: boolean }) {
useWindowAutoFollow(contentKey, enabled);
return
thread
;
}
/**
* jsdom has no layout: document scroll geometry is faked on the scrolling
* element, window.scrollTo is stubbed to record calls and update scrollY.
*/
describe("useWindowAutoFollow", () => {
let container: HTMLDivElement;
let root: Root;
let scrollToCalls: number[];
function fakeWindowGeometry({ scrollHeight = 2000, innerHeight = 800 } = {}) {
const el = document.scrollingElement ?? document.documentElement;
let currentScrollHeight = scrollHeight;
Object.defineProperty(el, "scrollHeight", {
get: () => currentScrollHeight,
configurable: true,
});
Object.defineProperty(window, "innerHeight", { value: innerHeight, configurable: true });
return {
setScrollHeight(value: number) {
currentScrollHeight = value;
},
};
}
function setWindowScrollY(y: number) {
Object.defineProperty(window, "scrollY", { value: y, configurable: true });
}
function render(contentKey: unknown, enabled = true) {
flushSync(() => {
root.render();
});
}
/** Fire a window scroll event and flush the continuous-priority update. */
function scrollWindowTo(y: number) {
setWindowScrollY(y);
window.dispatchEvent(new Event("scroll"));
return new Promise((resolve) => {
setTimeout(resolve);
});
}
/** Wait out the mount effect's rAF re-follow so it can't leak into asserts. */
function flushRaf() {
return new Promise((resolve) => {
requestAnimationFrame(() => setTimeout(resolve));
});
}
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
scrollToCalls = [];
fakeWindowGeometry();
setWindowScrollY(0);
vi.stubGlobal("scrollTo", (options?: ScrollToOptions | number) => {
const top = typeof options === "number" ? options : (options?.top ?? 0);
scrollToCalls.push(top);
// Mirror the browser: a follow to the bottom leaves the window pinned.
setWindowScrollY(Math.max(0, top - window.innerHeight));
});
});
afterEach(() => {
flushSync(() => root.unmount());
container.remove();
vi.unstubAllGlobals();
});
it("reapplies same-task targets and POP positions on mobile without remounting", async () => {
vi.stubGlobal("scrollTo", (options: ScrollToOptions) => setWindowScrollY(Math.min(1200, options.top ?? 0)));
function MobileThread() {
useWindowAutoFollow("unchanged", true);
return {[100, 500].map((top, index) => (
))}
;
}
const navigate = (key: string, hash: string, restore = false) => flushSync(() => root.render(
,
));
navigate("mobile-entry-one", "#mobile-comment-0");
const thread = container.firstElementChild;
expect(window.scrollY).toBe(100);
await scrollWindowTo(150);
navigate("mobile-entry-two", "#mobile-comment-1");
expect(container.firstElementChild).toBe(thread);
expect(window.scrollY).toBe(500);
navigate("mobile-entry-one", "#mobile-comment-0", true);
expect(window.scrollY).toBe(150);
navigate("mobile-entry-one", "#mobile-comment-1", true);
expect(window.scrollY).toBe(500);
});
it("owns browser restoration only while the mobile thread is enabled", () => {
window.history.scrollRestoration = "auto";
render(0);
expect(window.history.scrollRestoration).toBe("manual");
render(0, false);
expect(window.history.scrollRestoration).toBe("auto");
});
it("scrolls the window to the bottom on mount", () => {
render(0);
expect(scrollToCalls).toContain(2000);
});
it("follows content growth while pinned to the bottom", async () => {
render(0);
await flushRaf();
await scrollWindowTo(1200); // pinned: 2000 - 1200 - 800 = 0
scrollToCalls = [];
render(1);
expect(scrollToCalls).toContain(2000);
});
it("holds position when the user has scrolled up", async () => {
render(0);
await flushRaf();
await scrollWindowTo(100); // far from the bottom
scrollToCalls = [];
render(1);
expect(scrollToCalls).toHaveLength(0);
});
it("follows document growth while pinned on mobile", async () => {
let triggerResize = () => {};
vi.stubGlobal(
"ResizeObserver",
class {
constructor(callback: ResizeObserverCallback) {
triggerResize = () =>
callback([], this as unknown as ResizeObserver);
}
observe() {}
disconnect() {}
},
);
const geometry = fakeWindowGeometry();
render(0);
await flushRaf();
await scrollWindowTo(1200);
scrollToCalls = [];
geometry.setScrollHeight(2300);
triggerResize();
expect(scrollToCalls).toContain(2300);
});
it("holds document position through mobile composer growth when scrolled up", async () => {
let triggerResize = () => {};
vi.stubGlobal(
"ResizeObserver",
class {
constructor(callback: ResizeObserverCallback) {
triggerResize = () =>
callback([], this as unknown as ResizeObserver);
}
observe() {}
disconnect() {}
},
);
const geometry = fakeWindowGeometry();
render(0);
await flushRaf();
await scrollWindowTo(100);
scrollToCalls = [];
geometry.setScrollHeight(2300);
triggerResize();
expect(scrollToCalls).toHaveLength(0);
});
it("follows a late thread resize without a React content update or body resize", async () => {
const observed = new Set();
let resizeThread = () => {};
vi.stubGlobal("ResizeObserver", class {
constructor(callback: ResizeObserverCallback) {
resizeThread = () => {
const thread = container.querySelector('[data-testid="task-chat-thread"]')!;
if (observed.has(thread)) callback([{ target: thread } as ResizeObserverEntry], this as unknown as ResizeObserver);
};
}
observe(element: Element) { observed.add(element); }
disconnect() { observed.clear(); }
});
const geometry = fakeWindowGeometry();
render(0);
scrollToCalls = [];
geometry.setScrollHeight(2400);
resizeThread();
expect(scrollToCalls).toContain(2400);
expect(window.scrollY).toBe(1600);
});
it("keeps following when a scroll event arrives after growth before reconciliation", async () => {
const geometry = fakeWindowGeometry();
render(0);
geometry.setScrollHeight(2400);
await scrollWindowTo(1200); // Still at the old bottom; the user did not move up.
scrollToCalls = [];
render(1);
expect(scrollToCalls).toContain(2400);
});
it("holds an upward user scroll during growth and resumes after returning to the bottom", async () => {
const geometry = fakeWindowGeometry();
render(0);
geometry.setScrollHeight(2400);
await scrollWindowTo(900);
scrollToCalls = [];
render(1);
expect(scrollToCalls).toHaveLength(0);
await scrollWindowTo(1600);
geometry.setScrollHeight(2700);
render(2);
expect(scrollToCalls).toContain(2700);
});
it("follows viewport resizing while pinned and holds it while reading older output", async () => {
render(0);
Object.defineProperty(window, "innerHeight", { value: 600, configurable: true });
window.dispatchEvent(new Event("resize"));
expect(window.scrollY).toBe(1400);
await scrollWindowTo(500);
scrollToCalls = [];
Object.defineProperty(window, "innerHeight", { value: 700, configurable: true });
window.dispatchEvent(new Event("resize"));
expect(scrollToCalls).toHaveLength(0);
});
it("does nothing when disabled", () => {
render(0, false);
expect(scrollToCalls).toHaveLength(0);
render(1, false);
expect(scrollToCalls).toHaveLength(0);
});
});