murashit_codex-panel/tests/features/chat/ui/message-stream/virtualizer.test.ts

1220 lines
44 KiB
TypeScript

// @vitest-environment jsdom
import { afterEach, describe, expect, it } from "vitest";
import { h } from "preact";
import { useLayoutEffect, useRef } from "preact/hooks";
import { act } from "preact/test-utils";
import {
type MessageStreamVirtualizerHandle,
type MessageStreamVirtualizerView,
useMessageStreamVirtualizer,
} from "../../../../../src/features/chat/ui/message-stream/virtualizer";
import type { MessageStreamBlock } from "../../../../../src/features/chat/ui/message-stream/context";
import { renderUiRoot, unmountUiRoot } from "../../../../../src/shared/ui/ui-root";
import { installObsidianDomShims } from "../../../../support/dom";
installObsidianDomShims();
describe("TestMessageStreamVirtualizer", () => {
afterEach(() => {
document.body.replaceChildren();
});
it("pins to the virtualized end when content is appended while pinned", () => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
renderVirtualItems(controller, container, ["first"], [180], "follow-bottom");
expect(container.scrollTop).toBe(80);
renderVirtualItems(controller, container, ["first", "second"], [180, 220], "auto");
expect(container.scrollTop).toBe(300);
controller.dispose();
});
it("requests the end before measurements and remains there after measurements resolve", () => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
const blocks = [{ key: "first", node: null }];
container.dataset["testTotalSize"] = "180";
controller.render(blocks, "follow-bottom");
expect(container.scrollTop).toBe(80);
measureVirtualItem(controller, "first", 0, 180);
expect(controller.getTotalSize()).toBe(180);
expect(container.scrollTop).toBe(80);
controller.dispose();
});
it("settles at the end after the rendered virtualizer height reaches the DOM", () => {
withAnimationFrame((flushFrames) => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
const blocks = [{ key: "first", node: null }];
container.dataset["testTotalSize"] = "180";
container.dataset["testScrollHeight"] = "100";
container.dataset["testClampToScrollHeight"] = "true";
controller.render(blocks, "follow-bottom");
expect(container.scrollTop).toBe(0);
flushFrames();
expect(container.scrollTop).toBe(0);
delete container.dataset["testScrollHeight"];
flushFrames();
expect(container.scrollTop).toBe(80);
controller.dispose();
});
});
it("keeps settling for a few frames when the rendered virtualizer height is delayed", () => {
withAnimationFrame((flushFrames) => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
const blocks = [{ key: "first", node: null }];
container.dataset["testTotalSize"] = "180";
container.dataset["testScrollHeight"] = "100";
container.dataset["testClampToScrollHeight"] = "true";
controller.render(blocks, "follow-bottom");
expect(container.scrollTop).toBe(0);
flushFrames();
expect(container.scrollTop).toBe(0);
flushFrames();
expect(container.scrollTop).toBe(0);
delete container.dataset["testScrollHeight"];
flushFrames();
expect(container.scrollTop).toBe(80);
controller.dispose();
});
});
it("includes message container block padding in the virtualized end", () => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
container.style.paddingLeft = "12px";
const controller = createMessageStreamVirtualizerDriver(container);
renderVirtualItems(controller, container, ["first"], [180], "force-bottom");
expect(controller.getTotalSize()).toBe(204);
expect(container.scrollTop).toBe(104);
controller.dispose();
});
it("does not follow appended content after the user scrolls away from the end", () => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
renderVirtualItems(controller, container, ["first"], [300], "force-bottom");
expect(container.scrollTop).toBe(200);
userScrollTo(container, 80);
renderVirtualItems(controller, container, ["first", "second"], [300, 180], "auto");
expect(container.scrollTop).toBe(80);
controller.dispose();
});
it("repins when the user scrolls back to the end", () => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
renderVirtualItems(controller, container, ["first", "second"], [300, 180], "force-bottom");
expect(container.scrollTop).toBe(380);
userScrollTo(container, 80);
userScrollTo(container, 380);
renderVirtualItems(controller, container, ["first", "second", "third"], [300, 180, 140], "auto");
expect(container.scrollTop).toBe(520);
controller.dispose();
});
it("keeps forced bottom pinned when the programmatic scroll event sees stale DOM scroll size", () => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
renderVirtualItems(controller, container, ["first", "submitted"], [300, 100], "follow-bottom");
expect(container.scrollTop).toBe(300);
container.dataset["testScrollHeight"] = "420";
container.dispatchEvent(new Event("scroll"));
delete container.dataset["testScrollHeight"];
renderVirtualItems(controller, container, ["first", "submitted", "reply"], [300, 100, 100], "auto");
expect(container.scrollTop).toBe(400);
controller.dispose();
});
it("keeps pinned intent when a programmatic scroll event observes a stale non-bottom offset", () => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
renderVirtualItems(controller, container, ["first"], [300], "follow-bottom");
expect(container.scrollTop).toBe(200);
container.dataset["testTotalSize"] = "450";
container.dispatchEvent(new Event("scroll"));
renderVirtualItems(controller, container, ["first", "second"], [300, 150], "auto");
expect(container.scrollTop).toBe(350);
controller.dispose();
});
it("keeps following the end when a pinned item grows after measurement", () => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
renderVirtualItems(controller, container, ["first", "command"], [200, 100], "follow-bottom");
expect(container.scrollTop).toBe(200);
container.dataset["testTotalSize"] = "450";
measureVirtualItem(controller, "command", 1, 250);
expect(container.scrollTop).toBe(350);
controller.dispose();
});
it("settles at the end when a pinned item grows before the DOM scroll height catches up", () => {
withAnimationFrame((flushFrames) => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
renderVirtualItems(controller, container, ["first", "command"], [200, 100], "follow-bottom");
flushFrames();
flushFrames();
expect(container.scrollTop).toBe(200);
container.dataset["testTotalSize"] = "450";
container.dataset["testScrollHeight"] = "300";
container.dataset["testClampToScrollHeight"] = "true";
measureVirtualItem(controller, "command", 1, 250);
expect(container.scrollTop).toBe(200);
flushFrames();
expect(container.scrollTop).toBe(200);
delete container.dataset["testScrollHeight"];
flushFrames();
expect(container.scrollTop).toBe(350);
controller.dispose();
});
});
it("keeps following the end when pinned content grows in the DOM before measurement", () => {
withAnimationFrame((flushFrames) => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
renderVirtualItems(controller, container, ["first", "markdown"], [200, 100], "follow-bottom");
flushFrames();
flushFrames();
expect(container.scrollTop).toBe(200);
container.dataset["testScrollHeight"] = "461";
container.dataset["testClampToScrollHeight"] = "true";
container.dataset["testTotalSize"] = "450";
measureVirtualItem(controller, "markdown", 1, 250);
expect(container.scrollTop).toBe(361);
delete container.dataset["testScrollHeight"];
flushFrames();
expect(container.scrollTop).toBe(350);
controller.dispose();
});
});
it("keeps following the end when a non-last rendered message grows while pinned", () => {
withAnimationFrame((flushFrames) => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
renderVirtualItems(controller, container, ["first", "markdown", "live"], [160, 100, 80], "follow-bottom");
flushFrames();
flushFrames();
expect(container.scrollTop).toBe(240);
container.dataset["testScrollHeight"] = "500";
container.dataset["testClampToScrollHeight"] = "true";
container.dataset["testTotalSize"] = "500";
measureVirtualItem(controller, "markdown", 1, 260);
expect(container.scrollTop).toBe(400);
controller.dispose();
});
});
it("keeps the pending end settle when a stale programmatic scroll event fires during pinned item growth", () => {
withAnimationFrame((flushFrames) => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
renderVirtualItems(controller, container, ["first", "command"], [200, 100], "follow-bottom");
flushFrames();
flushFrames();
expect(container.scrollTop).toBe(200);
container.dataset["testTotalSize"] = "450";
container.dataset["testScrollHeight"] = "300";
container.dataset["testClampToScrollHeight"] = "true";
measureVirtualItem(controller, "command", 1, 250);
container.dispatchEvent(new Event("scroll"));
delete container.dataset["testScrollHeight"];
flushFrames();
flushFrames();
expect(container.scrollTop).toBe(350);
controller.dispose();
});
});
it("does not postpone the pending end settle while small pinned growth measurements keep arriving", () => {
withAnimationFrame((flushFrames) => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
renderVirtualItems(controller, container, ["first", "command"], [200, 100], "follow-bottom");
flushFrames();
flushFrames();
expect(container.scrollTop).toBe(200);
container.dataset["testScrollHeight"] = "300";
container.dataset["testClampToScrollHeight"] = "true";
container.dataset["testTotalSize"] = "320";
measureVirtualItem(controller, "command", 1, 120);
flushFrames();
container.dataset["testTotalSize"] = "340";
measureVirtualItem(controller, "command", 1, 140);
delete container.dataset["testScrollHeight"];
flushFrames();
expect(container.scrollTop).toBe(240);
controller.dispose();
});
});
it("settles at the end when ResizeObserver reports pinned item growth before DOM scroll height catches up", () => {
withResizeObserverEntries((resizeElement, flushFrames) => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
const command = measuredElement("command", 1, 100);
container.dataset["testTotalSize"] = "300";
controller.render(
[
{ key: "first", node: null },
{ key: "command", node: null },
],
"follow-bottom",
);
controller.getTotalSize();
measureVirtualItem(controller, "first", 0, 200);
controller.measureElement(command);
flushFrames();
flushFrames();
expect(container.scrollTop).toBe(200);
container.dataset["testTotalSize"] = "450";
container.dataset["testScrollHeight"] = "300";
container.dataset["testClampToScrollHeight"] = "true";
resizeElement(command, 250);
flushFrames();
expect(container.scrollTop).toBe(200);
flushFrames();
expect(container.scrollTop).toBe(200);
delete container.dataset["testScrollHeight"];
flushFrames();
expect(container.scrollTop).toBe(350);
controller.dispose();
});
});
it("does not postpone the pending end settle while ResizeObserver keeps reporting small pinned growth", () => {
withResizeObserverEntries((resizeElement, flushFrames) => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
const command = measuredElement("command", 1, 100);
container.dataset["testTotalSize"] = "300";
controller.render(
[
{ key: "first", node: null },
{ key: "command", node: null },
],
"follow-bottom",
);
controller.getTotalSize();
measureVirtualItem(controller, "first", 0, 200);
controller.measureElement(command);
flushFrames();
flushFrames();
expect(container.scrollTop).toBe(200);
container.dataset["testScrollHeight"] = "300";
container.dataset["testClampToScrollHeight"] = "true";
container.dataset["testTotalSize"] = "320";
resizeElement(command, 120);
flushFrames();
container.dataset["testTotalSize"] = "340";
resizeElement(command, 140);
delete container.dataset["testScrollHeight"];
flushFrames();
flushFrames();
expect(container.scrollTop).toBe(240);
controller.dispose();
});
});
it("defers ResizeObserver-triggered rerenders until the next animation frame", () => {
withResizeObserverEntries((resizeElement, flushFrames) => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
let renderCount = 0;
const controller = createMessageStreamVirtualizerDriver(container, {
onRender: () => {
renderCount += 1;
},
});
const command = measuredElement("command", 1, 100);
container.dataset["testTotalSize"] = "300";
controller.render(
[
{ key: "first", node: null },
{ key: "command", node: null },
],
"follow-bottom",
);
controller.getTotalSize();
measureVirtualItem(controller, "first", 0, 200);
controller.measureElement(command);
void act(flushFrames);
const settledRenderCount = renderCount;
container.dataset["testTotalSize"] = "450";
resizeElement(command, 250);
expect(renderCount).toBe(settledRenderCount);
void act(flushFrames);
expect(renderCount).toBeGreaterThan(settledRenderCount);
controller.dispose();
});
});
it("uses the pre-render bottom position when appending after an item resize", () => {
const container = messageContainer({ scrollTop: 0, clientHeight: 160 });
const controller = createMessageStreamVirtualizerDriver(container);
renderVirtualItems(controller, container, ["first", "second"], [200, 200], "follow-bottom");
expect(container.scrollTop).toBe(240);
container.dataset["testTotalSize"] = "480";
measureVirtualItem(controller, "second", 1, 280);
renderVirtualItems(controller, container, ["first", "second", "third"], [200, 280, 120], "auto");
expect(container.scrollTop).toBe(440);
controller.dispose();
});
it("keeps the current reading position when an item below the viewport grows while unpinned", () => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
renderVirtualItems(controller, container, ["first", "streaming"], [300, 100], "preserve");
container.scrollTop = 100;
container.dispatchEvent(new Event("scroll"));
container.dataset["testTotalSize"] = "550";
measureVirtualItem(controller, "streaming", 1, 250);
expect(container.scrollTop).toBe(100);
controller.dispose();
});
it("keeps the visible reading anchor when an earlier item resizes while unpinned", () => {
withResizeObserverEntries((resizeElement, flushFrames) => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
const first = measuredElement("first", 0, 300);
renderVirtualItems(controller, container, ["first", "second", "third"], [300, 300, 300], "preserve");
controller.measureElement(first);
userScrollTo(container, 360);
container.dataset["testTotalSize"] = "1100";
resizeElement(first, 500);
flushFrames();
expect(container.scrollTop).toBe(560);
controller.dispose();
});
});
it("keeps the current reading offset when a visible item resizes while unpinned", () => {
withResizeObserverEntries((resizeElement, flushFrames) => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
const second = measuredElement("second", 1, 300);
renderVirtualItems(controller, container, ["first", "second", "third"], [300, 300, 300], "preserve");
controller.measureElement(second);
userScrollTo(container, 360);
container.dataset["testTotalSize"] = "1050";
resizeElement(second, 450);
flushFrames();
expect(container.scrollTop).toBe(360);
controller.dispose();
});
});
it("keeps the reading position when the message viewport shrinks away from the end", () => {
withResizeObserver((triggerResize) => {
const container = messageContainer({ scrollTop: 0, clientHeight: 160 });
const controller = createMessageStreamVirtualizerDriver(container);
renderVirtualItems(controller, container, ["first", "second"], [240, 260], "force-bottom");
expect(container.scrollTop).toBe(340);
userScrollTo(container, 120);
setContainerClientHeight(container, 100);
triggerResize();
expect(container.scrollTop).toBe(120);
controller.dispose();
});
});
it("resets stale measurements when the message viewport is restored from zero size", () => {
withResizeObserver((triggerResize, flushFrames) => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
const first = renderedMeasuredElement("first", 0, 300);
first.append(renderedMeasuredElement("stale-nested", 0, 900));
appendRenderedVirtualizer(
container,
first,
renderedMeasuredElement("second", 1, 100),
renderedMeasuredElement("stale-sibling", 1, 900),
);
renderVirtualItems(controller, container, ["first", "second"], [300, 100], "follow-bottom");
expect(container.scrollTop).toBe(300);
userScrollTo(container, 120);
setContainerClientSize(container, { width: 0, height: 0 });
triggerResize();
setContainerClientSize(container, { width: 240, height: 100 });
triggerResize();
flushFrames();
expect(controller.getTotalSize()).toBe(400);
expect(container.scrollTop).toBe(300);
controller.dispose();
});
});
it("resets stale measurements when a hidden-at-start message viewport first renders at a usable size", () => {
withResizeObserver((_triggerResize, flushFrames) => {
const container = messageContainer({ scrollTop: 0, clientHeight: 0 });
const controller = createMessageStreamVirtualizerDriver(container);
appendRenderedVirtualizer(container, renderedMeasuredElement("first", 0, 300), renderedMeasuredElement("second", 1, 100));
setContainerClientSize(container, { width: 0, height: 0 });
renderVirtualItems(controller, container, ["first", "second"], [300, 100], "preserve");
setContainerClientSize(container, { width: 240, height: 100 });
controller.render(
[
{ key: "first", node: null },
{ key: "second", node: null },
],
"preserve",
);
flushFrames();
expect(controller.getTotalSize()).toBe(400);
expect(container.scrollTop).toBe(300);
controller.dispose();
});
});
it("scrolls by two text lines for composer edge shortcuts", () => {
const container = messageContainer({ scrollTop: 120, clientHeight: 100 });
container.style.lineHeight = "18px";
const controller = createMessageStreamVirtualizerDriver(container);
renderVirtualItems(controller, container, ["first", "second"], [240, 240], "preserve");
container.scrollTop = 120;
container.dispatchEvent(new Event("scroll"));
controller.scrollByTextLines(-1);
container.dispatchEvent(new Event("scroll"));
expect(container.scrollTop).toBe(84);
controller.scrollByTextLines(1);
container.dispatchEvent(new Event("scroll"));
expect(container.scrollTop).toBe(120);
controller.dispose();
});
it("keeps bottom-following intent when composer edge scrolling is already at the bottom", () => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
container.style.lineHeight = "18px";
const controller = createMessageStreamVirtualizerDriver(container);
renderVirtualItems(controller, container, ["first"], [300], "force-bottom");
expect(container.scrollTop).toBe(200);
controller.scrollByTextLines(1);
expect(container.scrollTop).toBe(200);
renderVirtualItems(controller, container, ["first", "second"], [300, 140], "auto");
expect(container.scrollTop).toBe(340);
controller.dispose();
});
it("repins when composer edge scrolling reaches the bottom", () => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
container.style.lineHeight = "18px";
const controller = createMessageStreamVirtualizerDriver(container);
renderVirtualItems(controller, container, ["first"], [300], "force-bottom");
userScrollTo(container, 180);
controller.scrollByTextLines(1);
expect(container.scrollTop).toBe(200);
container.dataset["testTotalSize"] = "340";
controller.repinToBottomIfPinned();
expect(container.scrollTop).toBe(240);
controller.dispose();
});
it("scrolls by text lines from the rendered bottom before a scroll event settles", () => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
container.style.lineHeight = "18px";
const controller = createMessageStreamVirtualizerDriver(container);
const keys = ["first", "second"];
container.dataset["testTotalSize"] = "480";
controller.render(
keys.map((key) => ({ key, node: null })),
"force-bottom",
);
controller.getTotalSize();
keys.forEach((key, index) => {
measureVirtualItem(controller, key, index, 240);
});
controller.getTotalSize();
expect(container.scrollTop).toBe(380);
controller.scrollByTextLines(-1);
container.dispatchEvent(new Event("scroll"));
expect(container.scrollTop).toBe(344);
controller.dispose();
});
it("preserves the visible anchor when composer edge scrolling measures earlier items", () => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
container.style.lineHeight = "18px";
const controller = createMessageStreamVirtualizerDriver(container);
renderVirtualItems(controller, container, ["first", "second", "third"], [240, 240, 240], "force-bottom");
expect(container.scrollTop).toBe(620);
controller.scrollByTextLines(-1);
container.dispatchEvent(new Event("scroll"));
expect(container.scrollTop).toBe(584);
const anchorBefore = virtualItemTop(controller, container, "third");
container.dataset["testTotalSize"] = "980";
measureVirtualItem(controller, "first", 0, 500);
expect(virtualItemTop(controller, container, "third")).toBe(anchorBefore);
controller.dispose();
});
it("treats body-targeted keyboard scrolling as message viewport user intent", () => {
withAnimationFrame((flushFrames) => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
renderVirtualItems(controller, container, ["first", "second"], [300, 200], "force-bottom");
expect(container.scrollTop).toBe(400);
document.body.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowUp" }));
container.scrollTop = 360;
container.dispatchEvent(new Event("scroll"));
flushFrames();
flushFrames();
expect(container.scrollTop).toBe(360);
controller.dispose();
});
});
it("scrolls by a viewport step for composer PageUp and PageDown shortcuts", () => {
const container = messageContainer({ scrollTop: 220, clientHeight: 200 });
const controller = createMessageStreamVirtualizerDriver(container);
renderVirtualItems(controller, container, ["first", "second", "third"], [240, 240, 240], "preserve");
container.scrollTop = 220;
container.dispatchEvent(new Event("scroll"));
controller.scrollByPage(-1);
container.dispatchEvent(new Event("scroll"));
expect(container.scrollTop).toBe(60);
controller.scrollByPage(1);
container.dispatchEvent(new Event("scroll"));
expect(container.scrollTop).toBe(220);
container.scrollTop = 510;
container.dispatchEvent(new Event("scroll"));
controller.scrollByPage(1);
container.dispatchEvent(new Event("scroll"));
expect(container.scrollTop).toBe(520);
controller.dispose();
});
it("preserves the visible anchor when composer PageUp measurements shrink earlier items", () => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
const keys = numberedKeys("item", 50);
container.dataset["testTotalSize"] = "4800";
controller.render(
keys.map((key) => ({ key, node: null })),
"force-bottom",
);
expect(container.scrollTop).toBe(4700);
controller.scrollByPage(-1);
expect(container.scrollTop).toBe(4620);
const anchorKey = firstVisibleVirtualItemKey(controller, container, keys);
const anchorBefore = virtualItemTop(controller, container, anchorKey);
for (const item of controller.getVirtualItems()) {
measureVirtualItem(controller, keys[item.index] ?? "", item.index, 20);
}
container.dataset["testTotalSize"] = String(controller.getTotalSize());
clampScrollTop(container);
container.dispatchEvent(new Event("scroll"));
expect(virtualItemTop(controller, container, anchorKey)).toBe(anchorBefore);
controller.dispose();
});
it("keeps repeated composer line scrolling anchored when measurements shrink", () => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
container.style.lineHeight = "18px";
const controller = createMessageStreamVirtualizerDriver(container);
const keys = numberedKeys("item", 50);
container.dataset["testTotalSize"] = "4800";
controller.render(
keys.map((key) => ({ key, node: null })),
"force-bottom",
);
userScrollTo(container, 4660);
controller.scrollByTextLines(-1);
expect(container.scrollTop).toBe(4624);
const anchorKey = firstVisibleVirtualItemKey(controller, container, keys);
const anchorBefore = virtualItemTop(controller, container, anchorKey);
for (const item of controller.getVirtualItems()) {
measureVirtualItem(controller, keys[item.index] ?? "", item.index, 20);
}
container.dataset["testTotalSize"] = String(controller.getTotalSize());
clampScrollTop(container);
container.dispatchEvent(new Event("scroll"));
expect(virtualItemTop(controller, container, anchorKey)).toBe(anchorBefore);
controller.scrollByTextLines(-1);
expect(virtualItemTop(controller, container, anchorKey)).toBe(anchorBefore + 36);
controller.dispose();
});
it("drops stale same-key measurements when force-bottom replaces the message stream", () => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
renderVirtualItems(controller, container, ["same-key"], [360], "force-bottom");
expect(controller.getTotalSize()).toBe(360);
expect(container.scrollTop).toBe(260);
renderVirtualItems(controller, container, ["same-key"], [120], "force-bottom");
expect(controller.getTotalSize()).toBe(120);
expect(container.scrollTop).toBe(20);
controller.dispose();
});
it("keeps same-key measurements while preserving the current reading position", () => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
renderVirtualItems(controller, container, ["same-key"], [360], "force-bottom");
userScrollTo(container, 140);
renderVirtualItems(controller, container, ["same-key"], [360], "preserve");
expect(controller.getTotalSize()).toBe(360);
expect(container.scrollTop).toBe(140);
controller.dispose();
});
it("keeps measured earlier blocks when following appended content", () => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
renderVirtualItems(controller, container, ["first"], [240], "follow-bottom");
renderVirtualItems(controller, container, ["first", "second"], [240, 160], "auto");
expect(controller.getTotalSize()).toBe(400);
expect(container.scrollTop).toBe(300);
controller.dispose();
});
it("renders the new message stream from the end after force-bottom replaces a long reading position", () => {
const container = messageContainer({ scrollTop: 0, clientHeight: 100 });
const controller = createMessageStreamVirtualizerDriver(container);
renderVirtualItems(
controller,
container,
numberedKeys("old", 80),
Array.from({ length: 80 }, () => 40),
"force-bottom",
);
userScrollTo(container, 2400);
controller.render(
numberedKeys("new", 8).map((key) => ({ key, node: null })),
"force-bottom",
);
expect(controller.getVirtualItems().map((item) => item.index)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]);
controller.dispose();
});
});
function renderVirtualItems(
controller: MessageStreamVirtualizerDriver,
container: HTMLElement,
keys: string[],
heights: number[],
intent: "auto" | "force-bottom" | "follow-bottom" | "preserve",
): void {
const blocks = keys.map((key) => ({ key, node: null }));
container.dataset["testTotalSize"] = String(heights.reduce((total, height) => total + height, 0) + messageBlockPadding(container) * 2);
controller.render(blocks, intent);
controller.getTotalSize();
keys.forEach((key, index) => {
measureVirtualItem(controller, key, index, heights[index] ?? 0);
});
controller.getTotalSize();
container.dispatchEvent(new Event("scroll"));
}
function firstVisibleVirtualItemKey(controller: MessageStreamVirtualizerDriver, container: HTMLElement, keys: string[]): string {
const firstItem = controller.getVirtualItems().find((item) => item.end > container.scrollTop);
if (!firstItem) throw new Error("Expected a rendered virtual item.");
return keys[firstItem.index] ?? "";
}
function virtualItemTop(controller: MessageStreamVirtualizerDriver, container: HTMLElement, key: string): number {
const item = controller.getVirtualItems().find((candidate) => candidate.key === key);
if (!item) throw new Error(`Expected virtual item ${key}.`);
return item.start - container.scrollTop;
}
function userScrollTo(container: HTMLElement, scrollTop: number): void {
container.dispatchEvent(new Event("wheel"));
container.scrollTop = scrollTop;
container.dispatchEvent(new Event("scroll"));
}
function clampScrollTop(container: HTMLElement): void {
const scrollTop = container.scrollTop;
container.scrollTop = scrollTop;
}
function measureVirtualItem(controller: MessageStreamVirtualizerDriver, key: string, index: number, height: number): void {
controller.measureElement(measuredElement(key, index, height));
}
function measuredElement(key: string, index: number, height: number): HTMLElement {
const element = document.createElement("div");
element.setAttribute("data-codex-panel-block-key", key);
element.setAttribute("data-index", String(index));
Object.defineProperty(element, "offsetHeight", { value: height, configurable: true });
Object.defineProperty(element, "isConnected", { value: true, configurable: true });
return element;
}
function renderedMeasuredElement(key: string, index: number, height: number): HTMLElement {
const element = measuredElement(key, index, height);
element.classList.add("codex-panel__message-block");
return element;
}
function appendRenderedVirtualizer(container: HTMLElement, ...blocks: HTMLElement[]): void {
const virtualizer = document.createElement("div");
virtualizer.classList.add("codex-panel__message-virtualizer");
virtualizer.append(...blocks);
container.append(virtualizer);
}
interface MessageStreamVirtualizerDriver extends MessageStreamVirtualizerHandle {
render(blocks: readonly MessageStreamBlock[], intent: "auto" | "force-bottom" | "follow-bottom" | "preserve"): void;
getTotalSize(): number;
getVirtualItems(): ReturnType<MessageStreamVirtualizerView["getVirtualItems"]>;
measureElement(element: HTMLElement | null): void;
dispose(): void;
}
function createMessageStreamVirtualizerDriver(
container: HTMLElement,
options: { onRender?: () => void } = {},
): MessageStreamVirtualizerDriver {
const host = document.createElement("div");
document.body.appendChild(host);
let view: MessageStreamVirtualizerView | null = null;
let handle: MessageStreamVirtualizerHandle | null = null;
return {
render(blocks, intent) {
const renderBlocks = [...blocks];
void act(() => {
renderUiRoot(
host,
h(VirtualizerHarness, {
blocks: renderBlocks,
container,
intent,
...(options.onRender ? { onRender: options.onRender } : {}),
onHandle: (next) => (handle = next),
onView: (next) => (view = next),
}),
);
});
},
getTotalSize() {
return view?.getTotalSize() ?? 0;
},
getVirtualItems() {
return view?.getVirtualItems() ?? [];
},
measureElement(element) {
view?.measureElement(element);
},
scrollByTextLines(direction) {
handle?.scrollByTextLines(direction);
},
scrollByPage(direction) {
handle?.scrollByPage(direction);
},
pinToBottom() {
handle?.pinToBottom();
},
repinToBottomIfPinned() {
handle?.repinToBottomIfPinned();
},
dispose() {
unmountUiRoot(host);
host.remove();
},
};
}
function VirtualizerHarness({
blocks,
container,
intent,
onRender,
onHandle,
onView,
}: {
blocks: readonly MessageStreamBlock[];
container: HTMLElement;
intent: "auto" | "force-bottom" | "follow-bottom" | "preserve";
onRender?: () => void;
onHandle: (handle: MessageStreamVirtualizerHandle | null) => void;
onView: (view: MessageStreamVirtualizerView) => void;
}) {
onRender?.();
const scrollElementRef = useRef<HTMLElement | null>(container);
const intentRef = useRef(intent);
intentRef.current = intent;
const view = useMessageStreamVirtualizer({
blocks,
scrollElementRef,
consumeScrollIntent: () => intentRef.current,
registerVirtualizer: (handle) => {
onHandle(handle);
return () => {
onHandle(null);
};
},
});
useLayoutEffect(() => {
onView(view);
}, [onView, view]);
return null;
}
function messageContainer(metrics: { scrollTop: number; clientHeight: number }): HTMLElement {
const container = document.createElement("div");
let scrollTop = metrics.scrollTop;
let clientHeight = metrics.clientHeight;
let clientWidth = 240;
container.dataset["testClientHeight"] = String(metrics.clientHeight);
container.dataset["testClientWidth"] = "240";
Object.defineProperties(container, {
scrollTop: {
get: () => scrollTop,
set: (value: number) => {
const scrollSize =
container.dataset["testClampToScrollHeight"] === "true"
? container.scrollHeight
: Math.max(container.scrollHeight, controllerTotalSize(container));
scrollTop = Math.max(0, Math.min(value, Math.max(0, scrollSize - clientHeight)));
},
configurable: true,
},
scrollHeight: {
get: () => {
const scrollHeight = Number(container.dataset["testScrollHeight"]);
return Number.isFinite(scrollHeight) && scrollHeight > 0 ? scrollHeight : controllerTotalSize(container);
},
configurable: true,
},
clientHeight: {
get: () => {
const nextClientHeight = Number(container.dataset["testClientHeight"]);
clientHeight = Number.isFinite(nextClientHeight) && nextClientHeight >= 0 ? nextClientHeight : clientHeight;
return clientHeight;
},
configurable: true,
},
offsetHeight: {
get: () => container.clientHeight,
configurable: true,
},
clientWidth: {
get: () => {
const nextClientWidth = Number(container.dataset["testClientWidth"]);
clientWidth = Number.isFinite(nextClientWidth) && nextClientWidth >= 0 ? nextClientWidth : clientWidth;
return clientWidth;
},
configurable: true,
},
offsetWidth: {
get: () => container.clientWidth,
configurable: true,
},
});
document.body.appendChild(container);
container.scrollTo = ((optionsOrX?: ScrollToOptions | number, y?: number) => {
const top = typeof optionsOrX === "number" ? (y ?? container.scrollTop) : (optionsOrX?.top ?? container.scrollTop);
container.scrollTop = top;
}) as typeof container.scrollTo;
return container;
}
function setContainerClientHeight(container: HTMLElement, clientHeight: number): void {
container.dataset["testClientHeight"] = String(clientHeight);
}
function setContainerClientSize(container: HTMLElement, size: { width: number; height: number }): void {
container.dataset["testClientWidth"] = String(size.width);
container.dataset["testClientHeight"] = String(size.height);
}
function numberedKeys(prefix: string, count: number): string[] {
return Array.from({ length: count }, (_value, index) => `${prefix}-${String(index)}`);
}
function controllerTotalSize(container: HTMLElement): number {
const totalSize = Number(container.dataset["testTotalSize"]);
return Number.isFinite(totalSize) && totalSize > 0 ? totalSize : 0;
}
function messageBlockPadding(container: HTMLElement): number {
const value = Number.parseFloat(container.ownerDocument.defaultView?.getComputedStyle(container).paddingLeft ?? "");
return Number.isFinite(value) ? value : 0;
}
function withResizeObserver(run: (triggerResize: () => void, flushFrames: () => void) => void): void {
const previousResizeObserver = window.ResizeObserver;
const previousRequestAnimationFrame = window.requestAnimationFrame;
const previousCancelAnimationFrame = window.cancelAnimationFrame;
const callbacks: ResizeObserverCallback[] = [];
const frames: FrameRequestCallback[] = [];
class TestResizeObserver implements ResizeObserver {
constructor(callback: ResizeObserverCallback) {
callbacks.push(callback);
}
observe(): void {
// Test callback triggering is explicit.
}
unobserve(): void {
// Test callback triggering is explicit.
}
disconnect(): void {
// Test callback triggering is explicit.
}
}
window.ResizeObserver = TestResizeObserver;
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
frames.push(callback);
return frames.length;
}) as typeof window.requestAnimationFrame;
window.cancelAnimationFrame = (() => undefined) as typeof window.cancelAnimationFrame;
try {
const flushFrames = () => {
for (const callback of callbacks) callback([], {} as ResizeObserver);
const pending = frames.splice(0);
for (const frame of pending) frame(0);
};
run(
() => {
frames.splice(0);
flushFrames();
},
() => {
const pending = frames.splice(0);
for (const frame of pending) frame(0);
},
);
} finally {
window.ResizeObserver = previousResizeObserver;
window.requestAnimationFrame = previousRequestAnimationFrame;
window.cancelAnimationFrame = previousCancelAnimationFrame;
}
}
function withAnimationFrame(run: (flushFrames: () => void) => void): void {
const previousRequestAnimationFrame = window.requestAnimationFrame;
const previousCancelAnimationFrame = window.cancelAnimationFrame;
const frames: { id: number; callback: FrameRequestCallback }[] = [];
let nextFrameId = 1;
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
const id = nextFrameId++;
frames.push({ id, callback });
return id;
}) as typeof window.requestAnimationFrame;
window.cancelAnimationFrame = ((id: number) => {
const index = frames.findIndex((frame) => frame.id === id);
if (index >= 0) frames.splice(index, 1);
}) as typeof window.cancelAnimationFrame;
try {
run(() => {
const pending = frames.splice(0);
for (const frame of pending) frame.callback(0);
});
} finally {
window.requestAnimationFrame = previousRequestAnimationFrame;
window.cancelAnimationFrame = previousCancelAnimationFrame;
}
}
function withResizeObserverEntries(
run: (resizeElement: (element: HTMLElement, height: number) => void, flushFrames: () => void) => void,
): void {
const previousResizeObserver = window.ResizeObserver;
const previousRequestAnimationFrame = window.requestAnimationFrame;
const previousCancelAnimationFrame = window.cancelAnimationFrame;
const callbacks: ResizeObserverCallback[] = [];
const frames: { id: number; callback: FrameRequestCallback }[] = [];
let nextFrameId = 1;
class TestResizeObserver implements ResizeObserver {
constructor(callback: ResizeObserverCallback) {
callbacks.push(callback);
}
observe(): void {
// Resize entries are triggered explicitly by the test.
}
unobserve(): void {
// Resize entries are triggered explicitly by the test.
}
disconnect(): void {
// Resize entries are triggered explicitly by the test.
}
}
window.ResizeObserver = TestResizeObserver;
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
const id = nextFrameId++;
frames.push({ id, callback });
return id;
}) as typeof window.requestAnimationFrame;
window.cancelAnimationFrame = ((id: number) => {
const index = frames.findIndex((frame) => frame.id === id);
if (index >= 0) frames.splice(index, 1);
}) as typeof window.cancelAnimationFrame;
try {
run(
(element, height) => {
Object.defineProperty(element, "offsetHeight", { value: height, configurable: true });
const entry = {
target: element,
borderBoxSize: [{ blockSize: height }],
contentBoxSize: [],
contentRect: element.getBoundingClientRect(),
devicePixelContentBoxSize: [],
} as unknown as ResizeObserverEntry;
for (const callback of callbacks) callback([entry], {} as ResizeObserver);
},
() => {
const pending = frames.splice(0);
for (const frame of pending) frame.callback(0);
},
);
} finally {
window.ResizeObserver = previousResizeObserver;
window.requestAnimationFrame = previousRequestAnimationFrame;
window.cancelAnimationFrame = previousCancelAnimationFrame;
}
}