mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat(site/src/pages/AgentsPage/components/ChatVirtualList): add true windowing with capture/restore scroll anchor
Recreate the @pierre/diffs windowing model directly: render only the items within the viewport plus a 1000px overscan, reserve the rest with top/bottom spacers sized from a per-id measured height cache, and recycle offscreen items. A single layout effect owns scrollTop, restoring a captured top-of-viewport anchor on content changes and re-capturing on pure scrolls. Validated by Storybook and unit tests; not wired in. Generated by Coder Agent on behalf of Danielle Maywood.
This commit is contained in:
@@ -1,37 +1,75 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { type FC, useRef, useState } from "react";
|
||||
import { expect, userEvent, waitFor, within } from "storybook/test";
|
||||
import { ChatVirtualList } from "./ChatVirtualList";
|
||||
import { ChatVirtualList, type VirtualItem } from "./ChatVirtualList";
|
||||
import type { MessageKind } from "./heightCache";
|
||||
|
||||
type Item = { id: number; height: number };
|
||||
type DemoItem = { id: string; kind: MessageKind; height: number };
|
||||
|
||||
const StoryHarness: FC<{ hasMoreMessages?: boolean }> = ({
|
||||
hasMoreMessages = false,
|
||||
}) => {
|
||||
const makeItems = (count: number, height = 220): DemoItem[] =>
|
||||
Array.from({ length: count }, (_, i) => ({
|
||||
id: `m-${i}`,
|
||||
kind: "assistant" as MessageKind,
|
||||
height,
|
||||
}));
|
||||
|
||||
const StoryHarness: FC<{
|
||||
initialCount?: number;
|
||||
hasMoreMessages?: boolean;
|
||||
itemHeight?: number;
|
||||
}> = ({ initialCount = 40, hasMoreMessages = false, itemHeight = 220 }) => {
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const scrollToBottomRef = useRef<(() => void) | null>(null);
|
||||
const nextId = useRef(40);
|
||||
const olderId = useRef(-1);
|
||||
const [items, setItems] = useState<Item[]>(() =>
|
||||
Array.from({ length: 40 }, (_, i) => ({ id: i, height: 120 })),
|
||||
const [items, setItems] = useState<DemoItem[]>(() =>
|
||||
makeItems(initialCount, itemHeight),
|
||||
);
|
||||
const [fetchCount, setFetchCount] = useState(0);
|
||||
|
||||
// Derive new ids purely from the previous list so the updater stays pure.
|
||||
// React StrictMode double-invokes updaters; a mutable counter would advance
|
||||
// twice and make the appended/prepended ids unpredictable.
|
||||
const append = () =>
|
||||
setItems((prev) => [...prev, { id: nextId.current++, height: 200 }]);
|
||||
setItems((prev) => {
|
||||
const nextIndex =
|
||||
prev.reduce((max, it) => {
|
||||
const n = it.id.startsWith("m-") ? Number(it.id.slice(2)) : -1;
|
||||
return Number.isNaN(n) ? max : Math.max(max, n);
|
||||
}, -1) + 1;
|
||||
return [
|
||||
...prev,
|
||||
{ id: `m-${nextIndex}`, kind: "assistant", height: itemHeight },
|
||||
];
|
||||
});
|
||||
const prepend = () =>
|
||||
setItems((prev) => [
|
||||
...Array.from({ length: 5 }, () => ({
|
||||
id: olderId.current--,
|
||||
setItems((prev) => {
|
||||
const minOld = prev.reduce((min, it) => {
|
||||
const n = it.id.startsWith("old-") ? Number(it.id.slice(4)) : 0;
|
||||
return Number.isNaN(n) ? min : Math.min(min, n);
|
||||
}, 0);
|
||||
const older = Array.from({ length: 5 }, (_, i) => ({
|
||||
id: `old-${minOld - 1 - i}`,
|
||||
kind: "user" as MessageKind,
|
||||
height: 120,
|
||||
})),
|
||||
...prev,
|
||||
]);
|
||||
}));
|
||||
return [...older, ...prev];
|
||||
});
|
||||
const growFive = () =>
|
||||
setItems((prev) =>
|
||||
prev.map((it) => (it.id === 5 ? { ...it, height: 320 } : it)),
|
||||
prev.map((it) =>
|
||||
it.id === "m-5" ? { ...it, height: it.height + 160 } : it,
|
||||
),
|
||||
);
|
||||
|
||||
const byId = new Map(items.map((it) => [it.id, it]));
|
||||
const renderItem = (item: VirtualItem) => {
|
||||
const demo = byId.get(item.id);
|
||||
return (
|
||||
<div data-testid={item.id} style={{ height: demo?.height ?? 120 }}>
|
||||
{item.id}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-[480px] w-[640px] flex-col">
|
||||
<div className="flex gap-1">
|
||||
@@ -47,23 +85,14 @@ const StoryHarness: FC<{ hasMoreMessages?: boolean }> = ({
|
||||
<span data-testid="fetch-count">{fetchCount}</span>
|
||||
</div>
|
||||
<ChatVirtualList
|
||||
items={items}
|
||||
renderItem={renderItem}
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
scrollToBottomRef={scrollToBottomRef}
|
||||
isFetchingMoreMessages={false}
|
||||
hasMoreMessages={hasMoreMessages}
|
||||
onFetchMoreMessages={() => setFetchCount((count) => count + 1)}
|
||||
messageCount={items.length}
|
||||
>
|
||||
{items.map((it) => (
|
||||
<div
|
||||
key={it.id}
|
||||
data-testid={`msg-${it.id}`}
|
||||
style={{ height: it.height }}
|
||||
>
|
||||
message {it.id}
|
||||
</div>
|
||||
))}
|
||||
</ChatVirtualList>
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -76,45 +105,80 @@ export default meta;
|
||||
|
||||
type Story = StoryObj<typeof ChatVirtualList>;
|
||||
|
||||
const offsetFromScrollerTop = (
|
||||
scroller: HTMLElement,
|
||||
el: HTMLElement,
|
||||
): number =>
|
||||
el.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
|
||||
|
||||
const distanceFromBottom = (scroller: HTMLElement): number =>
|
||||
scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight;
|
||||
|
||||
// visibleAnchorId returns the id of the first rendered item intersecting the
|
||||
// viewport top, picked dynamically so assertions do not depend on which items
|
||||
// the window chose to render.
|
||||
const visibleAnchorId = (
|
||||
canvasElement: HTMLElement,
|
||||
scroller: HTMLElement,
|
||||
): string => {
|
||||
const top = scroller.getBoundingClientRect().top;
|
||||
for (const el of canvasElement.querySelectorAll("[data-chat-item-id]")) {
|
||||
if (el.getBoundingClientRect().bottom > top + 5) {
|
||||
const id = el.getAttribute("data-chat-item-id");
|
||||
if (id) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error("no visible item");
|
||||
};
|
||||
|
||||
const offsetOfId = (
|
||||
canvasElement: HTMLElement,
|
||||
scroller: HTMLElement,
|
||||
id: string,
|
||||
): number => {
|
||||
const el = canvasElement.querySelector(`[data-chat-item-id="${id}"]`);
|
||||
if (!el) {
|
||||
return Number.NaN;
|
||||
}
|
||||
return el.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
|
||||
};
|
||||
|
||||
// settleWindow waits until the window has re-rendered around the current scroll
|
||||
// position, i.e. a real item sits at the viewport top. The window recompute is
|
||||
// async, so picking a reference item before settling races against it and may
|
||||
// observe a stale window from the previous scroll position.
|
||||
const settleWindow = async (
|
||||
canvasElement: HTMLElement,
|
||||
scroller: HTMLElement,
|
||||
): Promise<void> => {
|
||||
await waitFor(() => {
|
||||
const id = visibleAnchorId(canvasElement, scroller);
|
||||
expect(Math.abs(offsetOfId(canvasElement, scroller, id))).toBeLessThan(400);
|
||||
});
|
||||
};
|
||||
|
||||
export const RendersAndOverflows: Story = {
|
||||
render: () => <StoryHarness />,
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const scroller = canvas.getByTestId("scroll-container");
|
||||
await expect(scroller.scrollHeight).toBeGreaterThan(scroller.clientHeight);
|
||||
await waitFor(() =>
|
||||
expect(scroller.scrollHeight).toBeGreaterThan(scroller.clientHeight),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const AnchorStaysStableOnAboveViewportGrowth: Story = {
|
||||
render: () => <StoryHarness />,
|
||||
export const RendersOnlyWindow: Story = {
|
||||
render: () => <StoryHarness initialCount={200} />,
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const scroller = canvas.getByTestId("scroll-container");
|
||||
scroller.scrollTop = 1500;
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
await waitFor(() => expect(scroller.scrollTop).toBe(1500));
|
||||
const before = offsetFromScrollerTop(
|
||||
scroller,
|
||||
canvas.getByTestId("msg-15"),
|
||||
);
|
||||
await userEvent.click(canvas.getByTestId("grow-5"));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
Math.abs(
|
||||
offsetFromScrollerTop(scroller, canvas.getByTestId("msg-15")) -
|
||||
before,
|
||||
),
|
||||
).toBeLessThanOrEqual(2),
|
||||
expect(distanceFromBottom(scroller)).toBeLessThanOrEqual(16),
|
||||
);
|
||||
// Only the near-viewport window is rendered, not all 200 items, and a
|
||||
// far-above item is not mounted once the window has converged.
|
||||
await waitFor(() => expect(canvas.queryByTestId("m-2")).toBeNull());
|
||||
const rendered = canvasElement.querySelectorAll("[data-chat-item]");
|
||||
await expect(rendered.length).toBeLessThanOrEqual(40);
|
||||
const topSpacer = canvasElement.querySelector("[data-virtual-spacer]");
|
||||
await expect((topSpacer as HTMLElement).style.height).not.toBe("0px");
|
||||
},
|
||||
};
|
||||
|
||||
@@ -138,6 +202,9 @@ export const DoesNotYankWhenScrolledUp: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const scroller = canvas.getByTestId("scroll-container");
|
||||
await waitFor(() =>
|
||||
expect(distanceFromBottom(scroller)).toBeLessThanOrEqual(16),
|
||||
);
|
||||
scroller.scrollTop = 800;
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
await waitFor(() => expect(scroller.scrollTop).toBe(800));
|
||||
@@ -153,6 +220,9 @@ export const FetchesOlderNearTop: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const scroller = canvas.getByTestId("scroll-container");
|
||||
await waitFor(() =>
|
||||
expect(distanceFromBottom(scroller)).toBeLessThanOrEqual(16),
|
||||
);
|
||||
scroller.scrollTop = 0;
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
await waitFor(() =>
|
||||
@@ -163,35 +233,14 @@ export const FetchesOlderNearTop: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const PrependKeepsViewportStable: Story = {
|
||||
render: () => <StoryHarness />,
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const scroller = canvas.getByTestId("scroll-container");
|
||||
scroller.scrollTop = 1500;
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
await waitFor(() => expect(scroller.scrollTop).toBe(1500));
|
||||
const before = offsetFromScrollerTop(
|
||||
scroller,
|
||||
canvas.getByTestId("msg-20"),
|
||||
);
|
||||
await userEvent.click(canvas.getByTestId("prepend"));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
Math.abs(
|
||||
offsetFromScrollerTop(scroller, canvas.getByTestId("msg-20")) -
|
||||
before,
|
||||
),
|
||||
).toBeLessThanOrEqual(2),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const ScrollToBottomButtonWorks: Story = {
|
||||
render: () => <StoryHarness />,
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const scroller = canvas.getByTestId("scroll-container");
|
||||
await waitFor(() =>
|
||||
expect(distanceFromBottom(scroller)).toBeLessThanOrEqual(16),
|
||||
);
|
||||
scroller.scrollTop = 100;
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
const button = await canvas.findByRole("button", {
|
||||
@@ -203,3 +252,138 @@ export const ScrollToBottomButtonWorks: Story = {
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const NoJumpOnPrepend: Story = {
|
||||
render: () => <StoryHarness initialCount={200} />,
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const scroller = canvas.getByTestId("scroll-container");
|
||||
await waitFor(() =>
|
||||
expect(distanceFromBottom(scroller)).toBeLessThanOrEqual(16),
|
||||
);
|
||||
scroller.scrollTop = 20000;
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
await waitFor(() => expect(scroller.scrollTop).toBe(20000));
|
||||
await settleWindow(canvasElement, scroller);
|
||||
const ref = visibleAnchorId(canvasElement, scroller);
|
||||
const before = offsetOfId(canvasElement, scroller, ref);
|
||||
await userEvent.click(canvas.getByTestId("prepend"));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
Math.abs(offsetOfId(canvasElement, scroller, ref) - before),
|
||||
).toBeLessThanOrEqual(3),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const StreamingGrowthAboveViewportNoJump: Story = {
|
||||
render: () => <StoryHarness initialCount={200} />,
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const scroller = canvas.getByTestId("scroll-container");
|
||||
await waitFor(() =>
|
||||
expect(distanceFromBottom(scroller)).toBeLessThanOrEqual(16),
|
||||
);
|
||||
// Put m-5 (offset 1100) into the top overscan, above the viewport top.
|
||||
scroller.scrollTop = 1800;
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
await waitFor(() => expect(scroller.scrollTop).toBe(1800));
|
||||
await settleWindow(canvasElement, scroller);
|
||||
const ref = visibleAnchorId(canvasElement, scroller);
|
||||
const before = offsetOfId(canvasElement, scroller, ref);
|
||||
// m-5 grows above the viewport; the visible reference must not move.
|
||||
await userEvent.click(canvas.getByTestId("grow-5"));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
Math.abs(offsetOfId(canvasElement, scroller, ref) - before),
|
||||
).toBeLessThanOrEqual(3),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const NoJumpScrollingThroughWrongEstimates: Story = {
|
||||
// Real height 120 differs sharply from the assistant seed 220, so items
|
||||
// entering the top of the window reconcile estimate to measured. Scrolling up
|
||||
// by a delta must move a tracked item by exactly that delta.
|
||||
render: () => <StoryHarness initialCount={200} itemHeight={120} />,
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const scroller = canvas.getByTestId("scroll-container");
|
||||
await waitFor(() =>
|
||||
expect(distanceFromBottom(scroller)).toBeLessThanOrEqual(16),
|
||||
);
|
||||
scroller.scrollTop = 10000;
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
await waitFor(() => expect(scroller.scrollTop).toBe(10000));
|
||||
await settleWindow(canvasElement, scroller);
|
||||
const ref = visibleAnchorId(canvasElement, scroller);
|
||||
const before = offsetOfId(canvasElement, scroller, ref);
|
||||
scroller.scrollTop = 9500;
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
Math.abs(offsetOfId(canvasElement, scroller, ref) - (before + 500)),
|
||||
).toBeLessThanOrEqual(5),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const OnlyWindowMounts: Story = {
|
||||
render: () => <StoryHarness initialCount={1000} />,
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const scroller = canvas.getByTestId("scroll-container");
|
||||
await waitFor(() =>
|
||||
expect(distanceFromBottom(scroller)).toBeLessThanOrEqual(16),
|
||||
);
|
||||
// The newest item is mounted at the bottom.
|
||||
await waitFor(() => expect(canvas.queryByTestId("m-999")).not.toBeNull());
|
||||
// DOM node count stays bounded by the window, independent of the 1000-item
|
||||
// list size: a render-all implementation would mount far more than this.
|
||||
const rendered = canvasElement.querySelectorAll("[data-chat-item]");
|
||||
await expect(rendered.length).toBeLessThanOrEqual(40);
|
||||
},
|
||||
};
|
||||
|
||||
export const AppendKeepsPinned: Story = {
|
||||
render: () => <StoryHarness initialCount={1000} />,
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const scroller = canvas.getByTestId("scroll-container");
|
||||
await waitFor(() =>
|
||||
expect(distanceFromBottom(scroller)).toBeLessThanOrEqual(16),
|
||||
);
|
||||
await userEvent.click(canvas.getByTestId("append"));
|
||||
// The appended item mounts and the scroller stays pinned to the bottom.
|
||||
await waitFor(() => expect(canvas.queryByTestId("m-1000")).not.toBeNull());
|
||||
await waitFor(() =>
|
||||
expect(distanceFromBottom(scroller)).toBeLessThanOrEqual(16),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const ScrollRecyclesOffscreen: Story = {
|
||||
render: () => <StoryHarness initialCount={200} />,
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const scroller = canvas.getByTestId("scroll-container");
|
||||
await waitFor(() =>
|
||||
expect(distanceFromBottom(scroller)).toBeLessThanOrEqual(16),
|
||||
);
|
||||
// Bring m-100 into the window.
|
||||
scroller.scrollTop = 22000;
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
await waitFor(() => expect(canvas.queryByTestId("m-100")).not.toBeNull());
|
||||
// Scroll far below m-100 so it falls outside the overscan window.
|
||||
scroller.scrollTop = 40000;
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
// The offscreen row leaves the DOM (true recycling) and the top spacer
|
||||
// grows to reserve the space of every item now scrolled above the window,
|
||||
// including m-100's slot at offset 22000.
|
||||
await waitFor(() => expect(canvas.queryByTestId("m-100")).toBeNull());
|
||||
const topSpacer = canvasElement.querySelector("[data-virtual-spacer]");
|
||||
await expect(
|
||||
Number.parseInt((topSpacer as HTMLElement).style.height, 10),
|
||||
).toBeGreaterThan(22000);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -6,34 +6,94 @@ import {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
createHeightCache,
|
||||
type HeightCache,
|
||||
type MessageKind,
|
||||
} from "./heightCache";
|
||||
import { ScrollToBottomButton } from "./ScrollToBottomButton";
|
||||
import { useScrollAnchor } from "./useScrollAnchor";
|
||||
import { computeWindow, cumulativeOffsets } from "./windowMath";
|
||||
|
||||
export type VirtualItem = { id: string; kind: MessageKind };
|
||||
|
||||
// Render this many pixels above and below the viewport, matching the
|
||||
// @pierre/diffs Virtualizer overscan so fast scrolls do not blank.
|
||||
const VIRTUAL_OVERSCAN = 1000;
|
||||
const LOAD_MORE_MARGIN = "600px 0px 0px 0px";
|
||||
|
||||
type ChatVirtualListProps = {
|
||||
items: ReadonlyArray<VirtualItem>;
|
||||
renderItem: (item: VirtualItem) => ReactNode;
|
||||
scrollContainerRef: RefObject<HTMLDivElement | null>;
|
||||
scrollToBottomRef: RefObject<(() => void) | null>;
|
||||
isFetchingMoreMessages: boolean;
|
||||
hasMoreMessages: boolean;
|
||||
onFetchMoreMessages: () => void;
|
||||
messageCount: number;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
// The cached height sizes this item's spacer once it scrolls out of the
|
||||
// window, so leaving the window is layout-neutral.
|
||||
const MeasuredItem: FC<{
|
||||
item: VirtualItem;
|
||||
renderItem: (item: VirtualItem) => ReactNode;
|
||||
cache: HeightCache;
|
||||
onMeasured: () => void;
|
||||
}> = ({ item, renderItem, cache, onMeasured }) => {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
const observer = new ResizeObserver(() => {
|
||||
const height = el.getBoundingClientRect().height;
|
||||
if (height !== cache.get(item.id)) {
|
||||
cache.record(item.id, item.kind, height);
|
||||
onMeasured();
|
||||
}
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [item, cache, onMeasured]);
|
||||
|
||||
return (
|
||||
<div ref={ref} data-chat-item="" data-chat-item-id={item.id}>
|
||||
{renderItem(item)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ChatVirtualList: FC<ChatVirtualListProps> = ({
|
||||
items,
|
||||
renderItem,
|
||||
scrollContainerRef,
|
||||
scrollToBottomRef,
|
||||
isFetchingMoreMessages,
|
||||
hasMoreMessages,
|
||||
onFetchMoreMessages,
|
||||
messageCount,
|
||||
children,
|
||||
}) => {
|
||||
const { scrollerRef, contentRef, atBottom, scrollToBottom, maintainPin } =
|
||||
useScrollAnchor();
|
||||
const {
|
||||
scrollerRef,
|
||||
contentRef,
|
||||
atBottom,
|
||||
scrollToBottom,
|
||||
captureAnchor,
|
||||
restoreAnchor,
|
||||
} = useScrollAnchor();
|
||||
const topSentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
// Lazy useState initializer keeps one cache instance for the component's life
|
||||
// without reading a ref during render, which the React Compiler forbids.
|
||||
const [cache] = useState<HeightCache>(() => createHeightCache());
|
||||
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const [viewportHeight, setViewportHeight] = useState(0);
|
||||
const [cacheVersion, setCacheVersion] = useState(0);
|
||||
const bumpCacheVersion = useCallback(() => {
|
||||
setCacheVersion((value) => value + 1);
|
||||
}, []);
|
||||
|
||||
const setScroller = useCallback(
|
||||
(element: HTMLDivElement | null) => {
|
||||
@@ -44,10 +104,102 @@ export const ChatVirtualList: FC<ChatVirtualListProps> = ({
|
||||
[scrollerRef, scrollContainerRef, scrollToBottomRef, scrollToBottom],
|
||||
);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies(messageCount): messageCount is an intentional trigger so a new message pins to the bottom; maintainPin reads refs and must re-run when the count changes.
|
||||
const heights = items.map((item) => cache.estimate(item.id, item.kind));
|
||||
const offsets = cumulativeOffsets(heights);
|
||||
const { start, end, topPad, bottomPad } = computeWindow({
|
||||
offsets,
|
||||
scrollTop,
|
||||
// Until the scroller is measured, fall back to a 1px viewport so the first
|
||||
// window stays small (overscan only) instead of mounting the whole list.
|
||||
viewportHeight: viewportHeight || 1,
|
||||
overscan: VIRTUAL_OVERSCAN,
|
||||
});
|
||||
const visible = end >= start ? items.slice(start, end + 1) : [];
|
||||
|
||||
// Mirror the live scroll position into state so the window recomputes as the
|
||||
// reader scrolls. The anchor is captured by the layout effect below, after the
|
||||
// new window commits, never here. A teleport scroll therefore records a real
|
||||
// rendered item instead of a stale one from the previous window.
|
||||
useEffect(() => {
|
||||
const scroller = scrollerRef.current;
|
||||
if (!scroller) {
|
||||
return;
|
||||
}
|
||||
let frame = 0;
|
||||
const onScroll = () => {
|
||||
if (frame) {
|
||||
return;
|
||||
}
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = 0;
|
||||
setScrollTop(scroller.scrollTop);
|
||||
});
|
||||
};
|
||||
scroller.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => {
|
||||
scroller.removeEventListener("scroll", onScroll);
|
||||
if (frame) {
|
||||
cancelAnimationFrame(frame);
|
||||
}
|
||||
};
|
||||
}, [scrollerRef]);
|
||||
|
||||
// A resize counts as a content change below, so the anchor (or bottom pin)
|
||||
// is preserved across it.
|
||||
useEffect(() => {
|
||||
const scroller = scrollerRef.current;
|
||||
if (!scroller) {
|
||||
return;
|
||||
}
|
||||
const observer = new ResizeObserver(() => {
|
||||
setViewportHeight(scroller.clientHeight);
|
||||
});
|
||||
observer.observe(scroller);
|
||||
setViewportHeight(scroller.clientHeight);
|
||||
return () => observer.disconnect();
|
||||
}, [scrollerRef]);
|
||||
|
||||
// Single scrollTop owner. After every commit, classify the change by diffing
|
||||
// the committed inputs against the previous commit:
|
||||
// - content changed (items, measured heights, or viewport): the DOM shifted
|
||||
// under the reader, so restore the captured anchor (or re-pin to the
|
||||
// bottom), then re-capture at the settled position.
|
||||
// - only scrollTop changed (a deliberate scroll): just re-capture; restoring
|
||||
// here would fight the scroll.
|
||||
// restore writes scroller.scrollTop, which the scroll listener turns into the
|
||||
// next scrollTop state, so a correction converges across frames.
|
||||
const prevCommitRef = useRef<{
|
||||
scrollTop: number;
|
||||
items: ReadonlyArray<VirtualItem>;
|
||||
cacheVersion: number;
|
||||
viewportHeight: number;
|
||||
} | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
maintainPin();
|
||||
}, [messageCount, maintainPin]);
|
||||
const prev = prevCommitRef.current;
|
||||
prevCommitRef.current = { scrollTop, items, cacheVersion, viewportHeight };
|
||||
if (!prev) {
|
||||
restoreAnchor();
|
||||
captureAnchor();
|
||||
return;
|
||||
}
|
||||
const contentChanged =
|
||||
items !== prev.items ||
|
||||
cacheVersion !== prev.cacheVersion ||
|
||||
viewportHeight !== prev.viewportHeight;
|
||||
if (contentChanged) {
|
||||
restoreAnchor();
|
||||
captureAnchor();
|
||||
} else if (scrollTop !== prev.scrollTop) {
|
||||
captureAnchor();
|
||||
}
|
||||
}, [
|
||||
scrollTop,
|
||||
viewportHeight,
|
||||
items,
|
||||
cacheVersion,
|
||||
restoreAnchor,
|
||||
captureAnchor,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const sentinel = topSentinelRef.current;
|
||||
@@ -85,7 +237,17 @@ export const ChatVirtualList: FC<ChatVirtualListProps> = ({
|
||||
>
|
||||
<div ref={contentRef} className="flex flex-col">
|
||||
<div ref={topSentinelRef} aria-hidden className="h-0" />
|
||||
{children}
|
||||
<div data-virtual-spacer="" style={{ height: topPad }} />
|
||||
{visible.map((item) => (
|
||||
<MeasuredItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
renderItem={renderItem}
|
||||
cache={cache}
|
||||
onMeasured={bumpCacheVersion}
|
||||
/>
|
||||
))}
|
||||
<div data-virtual-spacer="" style={{ height: bottomPad }} />
|
||||
</div>
|
||||
</div>
|
||||
<ScrollToBottomButton
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createHeightCache } from "./heightCache";
|
||||
|
||||
describe("createHeightCache", () => {
|
||||
it("uses a provided seed when a kind has no samples", () => {
|
||||
const cache = createHeightCache({ assistant: 200 });
|
||||
expect(cache.estimate("a1", "assistant")).toBe(200);
|
||||
});
|
||||
|
||||
it("falls back to a built-in seed when none is provided", () => {
|
||||
const cache = createHeightCache();
|
||||
expect(cache.estimate("u1", "user")).toBe(80);
|
||||
});
|
||||
|
||||
it("averages recorded heights per kind", () => {
|
||||
const cache = createHeightCache();
|
||||
cache.record("a1", "assistant", 100);
|
||||
cache.record("a2", "assistant", 300);
|
||||
expect(cache.estimate("a3", "assistant")).toBe(200);
|
||||
});
|
||||
|
||||
it("returns the measured height for a known id via get and estimate", () => {
|
||||
const cache = createHeightCache();
|
||||
cache.record("a1", "assistant", 512);
|
||||
expect(cache.get("a1")).toBe(512);
|
||||
expect(cache.estimate("a1", "assistant")).toBe(512);
|
||||
});
|
||||
|
||||
it("updates the average when an id is re-recorded, without double counting", () => {
|
||||
const cache = createHeightCache();
|
||||
cache.record("a1", "assistant", 100);
|
||||
cache.record("a1", "assistant", 200);
|
||||
expect(cache.estimate("a2", "assistant")).toBe(200);
|
||||
});
|
||||
|
||||
it("returns undefined from get for an unknown id", () => {
|
||||
const cache = createHeightCache();
|
||||
expect(cache.get("nope")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps averages per kind independent", () => {
|
||||
const cache = createHeightCache();
|
||||
cache.record("u1", "user", 60);
|
||||
cache.record("a1", "assistant", 400);
|
||||
expect(cache.estimate("u2", "user")).toBe(60);
|
||||
expect(cache.estimate("a2", "assistant")).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
// Type-aware height model for the windowing renderer. Pure mutable state, read
|
||||
// on render and updated from measurement. No DOM or React here.
|
||||
|
||||
export type MessageKind = "user" | "assistant" | "tool" | "diff" | "other";
|
||||
|
||||
export type HeightCache = {
|
||||
// get returns the measured height for an id, or undefined if never measured.
|
||||
get(id: string): number | undefined;
|
||||
// estimate returns the measured height if known, else the running average for
|
||||
// the kind, else the seed. Used to size not-yet-measured items.
|
||||
estimate(id: string, kind: MessageKind): number;
|
||||
// record stores an id's measured height and folds it into the kind average.
|
||||
record(id: string, kind: MessageKind, height: number): void;
|
||||
};
|
||||
|
||||
// Seeds only affect the first paint of a kind before any of its messages have
|
||||
// been measured. They are intentionally rough.
|
||||
const DEFAULT_SEEDS: Record<MessageKind, number> = {
|
||||
user: 80,
|
||||
assistant: 220,
|
||||
tool: 140,
|
||||
diff: 320,
|
||||
other: 160,
|
||||
};
|
||||
|
||||
export function createHeightCache(
|
||||
seeds?: Partial<Record<MessageKind, number>>,
|
||||
): HeightCache {
|
||||
const seed: Record<MessageKind, number> = { ...DEFAULT_SEEDS, ...seeds };
|
||||
const measured = new Map<string, { kind: MessageKind; height: number }>();
|
||||
const totals = new Map<MessageKind, { sum: number; count: number }>();
|
||||
|
||||
const kindAverage = (kind: MessageKind): number | undefined => {
|
||||
const total = totals.get(kind);
|
||||
if (!total || total.count === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return total.sum / total.count;
|
||||
};
|
||||
|
||||
return {
|
||||
get(id) {
|
||||
return measured.get(id)?.height;
|
||||
},
|
||||
estimate(id, kind) {
|
||||
return measured.get(id)?.height ?? kindAverage(kind) ?? seed[kind];
|
||||
},
|
||||
record(id, kind, height) {
|
||||
const previous = measured.get(id);
|
||||
const total = totals.get(kind) ?? { sum: 0, count: 0 };
|
||||
// Replace the previous sample so re-measuring never double counts.
|
||||
if (previous && previous.kind === kind) {
|
||||
total.sum += height - previous.height;
|
||||
} else {
|
||||
total.sum += height;
|
||||
total.count += 1;
|
||||
}
|
||||
totals.set(kind, total);
|
||||
measured.set(id, { kind, height });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { type FC, useLayoutEffect, useState } from "react";
|
||||
import { expect, userEvent, waitFor, within } from "storybook/test";
|
||||
import { useScrollAnchor } from "./useScrollAnchor";
|
||||
|
||||
// Exercises the capture/restore primitive in isolation, without the windowing
|
||||
// renderer, so a failure here points at the anchor hook rather than the layout
|
||||
// math.
|
||||
|
||||
const ROWS = 40;
|
||||
const ROW_HEIGHT = 100;
|
||||
const SPACER_HEIGHT = 50;
|
||||
|
||||
const AnchorHarness: FC = () => {
|
||||
const { scrollerRef, contentRef, captureAnchor, restoreAnchor } =
|
||||
useScrollAnchor();
|
||||
const [heights, setHeights] = useState<Record<number, number>>({});
|
||||
const [version, setVersion] = useState(0);
|
||||
|
||||
const mutate = (changes: Record<number, number>) => {
|
||||
// Record the item at the viewport top before the heights change, then
|
||||
// restore it once the mutation commits. This mirrors how the container
|
||||
// brackets a content mutation around the layout effect.
|
||||
captureAnchor();
|
||||
setHeights((prev) => ({ ...prev, ...changes }));
|
||||
setVersion((value) => value + 1);
|
||||
};
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies(version): version is the layout-mutation trigger; restore reads the DOM and must run after each mutation commits.
|
||||
useLayoutEffect(() => {
|
||||
restoreAnchor();
|
||||
}, [version, restoreAnchor]);
|
||||
|
||||
return (
|
||||
<div className="flex h-[400px] w-[600px] flex-col">
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="grow-above"
|
||||
onClick={() => mutate({ 5: 300 })}
|
||||
>
|
||||
grow-above
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="zero-net"
|
||||
onClick={() => mutate({ 5: 200, 35: 0 })}
|
||||
>
|
||||
zero-net
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
ref={scrollerRef}
|
||||
data-testid="scroll-container"
|
||||
className="flex min-h-0 flex-1 flex-col overflow-y-auto [overflow-anchor:none]"
|
||||
>
|
||||
<div ref={contentRef} className="flex flex-col">
|
||||
<div data-virtual-spacer="" style={{ height: SPACER_HEIGHT }} />
|
||||
{Array.from({ length: ROWS }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
data-chat-item=""
|
||||
data-chat-item-id={`row-${i}`}
|
||||
data-testid={`row-${i}`}
|
||||
style={{ height: heights[i] ?? ROW_HEIGHT }}
|
||||
>
|
||||
row {i}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const meta: Meta<typeof AnchorHarness> = {
|
||||
title: "pages/AgentsPage/ChatVirtualList/useScrollAnchor",
|
||||
component: AnchorHarness,
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof AnchorHarness>;
|
||||
|
||||
const offsetFromTop = (scroller: HTMLElement, el: HTMLElement): number =>
|
||||
el.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
|
||||
|
||||
// Scroll so row 20 sits at the viewport top: spacer + 20 rows.
|
||||
const SCROLL_TO_ROW_20 = SPACER_HEIGHT + 20 * ROW_HEIGHT;
|
||||
|
||||
export const AnchorSurvivesAboveMutation: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const scroller = canvas.getByTestId("scroll-container");
|
||||
scroller.scrollTop = SCROLL_TO_ROW_20;
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
await waitFor(() => expect(scroller.scrollTop).toBe(SCROLL_TO_ROW_20));
|
||||
const before = offsetFromTop(scroller, canvas.getByTestId("row-20"));
|
||||
await userEvent.click(canvas.getByTestId("grow-above"));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
Math.abs(
|
||||
offsetFromTop(scroller, canvas.getByTestId("row-20")) - before,
|
||||
),
|
||||
).toBeLessThanOrEqual(2),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const AnchorSurvivesZeroNetMutation: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const scroller = canvas.getByTestId("scroll-container");
|
||||
scroller.scrollTop = SCROLL_TO_ROW_20;
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
await waitFor(() => expect(scroller.scrollTop).toBe(SCROLL_TO_ROW_20));
|
||||
const before = offsetFromTop(scroller, canvas.getByTestId("row-20"));
|
||||
// Grows a row above the anchor and shrinks a row below it by the same
|
||||
// amount: total height is unchanged, so a content ResizeObserver never
|
||||
// fires, yet the anchor moved. Only capture/restore corrects this.
|
||||
await userEvent.click(canvas.getByTestId("zero-net"));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
Math.abs(
|
||||
offsetFromTop(scroller, canvas.getByTestId("row-20")) - before,
|
||||
),
|
||||
).toBeLessThanOrEqual(2),
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -7,34 +7,65 @@ import {
|
||||
} from "react";
|
||||
import { correctedScrollTop, isAtBottom } from "./anchorMath";
|
||||
|
||||
type Anchor = { el: HTMLElement; offset: number };
|
||||
// The anchor is keyed by a stable item id so it survives windowing remounts.
|
||||
// The element ref is a fast path; if it has been unmounted we re-find by id.
|
||||
type Anchor = { id: string | null; el: HTMLElement; offset: number };
|
||||
|
||||
// findTopAnchor returns the first child intersecting the viewport top, together
|
||||
// with its current offset from the scroller top. That child is what we keep
|
||||
// fixed when content above it changes height.
|
||||
const ITEM_SELECTOR = "[data-chat-item]";
|
||||
|
||||
// findTopAnchor returns the first rendered item intersecting the viewport top.
|
||||
// Spacers and other non-item children are ignored so the anchor is always a
|
||||
// real element whose offset we can preserve.
|
||||
function findTopAnchor(
|
||||
scroller: HTMLElement,
|
||||
content: HTMLElement,
|
||||
): Anchor | null {
|
||||
const scrollerTop = scroller.getBoundingClientRect().top;
|
||||
for (const child of content.children) {
|
||||
for (const child of content.querySelectorAll(ITEM_SELECTOR)) {
|
||||
if (!(child instanceof HTMLElement)) {
|
||||
continue;
|
||||
}
|
||||
const rect = child.getBoundingClientRect();
|
||||
if (rect.bottom > scrollerTop) {
|
||||
return { el: child, offset: rect.top - scrollerTop };
|
||||
return {
|
||||
id: child.getAttribute("data-chat-item-id"),
|
||||
el: child,
|
||||
offset: rect.top - scrollerTop,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveAnchorElement(
|
||||
content: HTMLElement,
|
||||
anchor: Anchor,
|
||||
): HTMLElement | null {
|
||||
if (anchor.el.isConnected) {
|
||||
return anchor.el;
|
||||
}
|
||||
if (anchor.id == null) {
|
||||
return null;
|
||||
}
|
||||
const found = content.querySelector(
|
||||
`[data-chat-item-id="${CSS.escape(anchor.id)}"]`,
|
||||
);
|
||||
return found instanceof HTMLElement ? found : null;
|
||||
}
|
||||
|
||||
type ScrollAnchor = {
|
||||
scrollerRef: RefObject<HTMLDivElement | null>;
|
||||
contentRef: RefObject<HTMLDivElement | null>;
|
||||
atBottom: boolean;
|
||||
scrollToBottom: () => void;
|
||||
maintainPin: () => void;
|
||||
// captureAnchor records the item currently at the viewport top. Callers run
|
||||
// it after every committed render so the anchor always reflects the latest
|
||||
// settled layout.
|
||||
captureAnchor: () => void;
|
||||
// restoreAnchor re-pins to the bottom when sticky, otherwise moves the
|
||||
// captured anchor element back to its recorded viewport offset. It is the
|
||||
// single owner of scrollTop correction and is idempotent.
|
||||
restoreAnchor: () => void;
|
||||
};
|
||||
|
||||
export function useScrollAnchor(): ScrollAnchor {
|
||||
@@ -55,14 +86,42 @@ export function useScrollAnchor(): ScrollAnchor {
|
||||
setAtBottom(true);
|
||||
}, []);
|
||||
|
||||
// maintainPin keeps the viewport pinned to the bottom while the user has not
|
||||
// scrolled away. Callers run this in a layout effect on content changes so
|
||||
// new messages pin synchronously, independent of ResizeObserver timing.
|
||||
const maintainPin = useCallback(() => {
|
||||
const captureAnchor = useCallback(() => {
|
||||
const scroller = scrollerRef.current;
|
||||
if (scroller && stickRef.current) {
|
||||
scroller.scrollTop = scroller.scrollHeight;
|
||||
const content = contentRef.current;
|
||||
if (!scroller || !content) {
|
||||
return;
|
||||
}
|
||||
anchorRef.current = stickRef.current
|
||||
? null
|
||||
: findTopAnchor(scroller, content);
|
||||
}, []);
|
||||
|
||||
const restoreAnchor = useCallback(() => {
|
||||
const scroller = scrollerRef.current;
|
||||
const content = contentRef.current;
|
||||
if (!scroller || !content) {
|
||||
return;
|
||||
}
|
||||
if (stickRef.current) {
|
||||
scroller.scrollTop = scroller.scrollHeight;
|
||||
return;
|
||||
}
|
||||
const anchor = anchorRef.current;
|
||||
if (!anchor) {
|
||||
return;
|
||||
}
|
||||
const el = resolveAnchorElement(content, anchor);
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
const offset =
|
||||
el.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
|
||||
scroller.scrollTop = correctedScrollTop(
|
||||
scroller.scrollTop,
|
||||
anchor.offset,
|
||||
offset,
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -76,10 +135,8 @@ export function useScrollAnchor(): ScrollAnchor {
|
||||
const onScroll = () => {
|
||||
const scrollTop = scroller.scrollTop;
|
||||
// A real user scroll always moves scrollTop. Layout-induced scroll
|
||||
// events keep the same scrollTop, so processing them would recapture
|
||||
// the anchor against an already-mutated layout and make the resize
|
||||
// correction a no-op. Our own correction does move scrollTop, so it
|
||||
// still re-establishes a fresh anchor afterward.
|
||||
// events keep the same scrollTop; ignoring them avoids flipping the
|
||||
// stick state when our own correction nudges the position.
|
||||
if (scrollTop === lastScrollTopRef.current) {
|
||||
return;
|
||||
}
|
||||
@@ -90,7 +147,6 @@ export function useScrollAnchor(): ScrollAnchor {
|
||||
scroller.clientHeight,
|
||||
);
|
||||
stickRef.current = bottom;
|
||||
anchorRef.current = bottom ? null : findTopAnchor(scroller, content);
|
||||
if (frame) {
|
||||
return;
|
||||
}
|
||||
@@ -100,26 +156,12 @@ export function useScrollAnchor(): ScrollAnchor {
|
||||
});
|
||||
};
|
||||
|
||||
// ResizeObserver notifications run between layout and paint, so correcting
|
||||
// scrollTop here keeps the viewport stable without a visible jump. Safari
|
||||
// has no native scroll anchoring, which is why we must do this ourselves.
|
||||
// The ResizeObserver is a fallback trigger for async intrinsic growth
|
||||
// (streaming markdown, image load, late highlight) that happens without a
|
||||
// React render. Windowing, measurement, and prepend are driven explicitly
|
||||
// by the container's layout effect instead.
|
||||
const resize = new ResizeObserver(() => {
|
||||
if (stickRef.current) {
|
||||
scroller.scrollTop = scroller.scrollHeight;
|
||||
return;
|
||||
}
|
||||
const anchor = anchorRef.current;
|
||||
if (!anchor) {
|
||||
return;
|
||||
}
|
||||
const offset =
|
||||
anchor.el.getBoundingClientRect().top -
|
||||
scroller.getBoundingClientRect().top;
|
||||
scroller.scrollTop = correctedScrollTop(
|
||||
scroller.scrollTop,
|
||||
anchor.offset,
|
||||
offset,
|
||||
);
|
||||
restoreAnchor();
|
||||
});
|
||||
|
||||
scroller.addEventListener("scroll", onScroll, { passive: true });
|
||||
@@ -131,7 +173,14 @@ export function useScrollAnchor(): ScrollAnchor {
|
||||
cancelAnimationFrame(frame);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}, [restoreAnchor]);
|
||||
|
||||
return { scrollerRef, contentRef, atBottom, scrollToBottom, maintainPin };
|
||||
return {
|
||||
scrollerRef,
|
||||
contentRef,
|
||||
atBottom,
|
||||
scrollToBottom,
|
||||
captureAnchor,
|
||||
restoreAnchor,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeWindow, cumulativeOffsets } from "./windowMath";
|
||||
|
||||
describe("cumulativeOffsets", () => {
|
||||
it("returns N+1 offsets with the total last", () => {
|
||||
expect(cumulativeOffsets([100, 200, 50])).toEqual([0, 100, 300, 350]);
|
||||
});
|
||||
it("returns [0] for an empty list", () => {
|
||||
expect(cumulativeOffsets([])).toEqual([0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeWindow", () => {
|
||||
const overscan = 1000;
|
||||
|
||||
it("renders the whole list when it fits the window", () => {
|
||||
const offsets = cumulativeOffsets([100, 200, 50]);
|
||||
expect(
|
||||
computeWindow({ offsets, scrollTop: 0, viewportHeight: 1000, overscan }),
|
||||
).toEqual({ start: 0, end: 2, topPad: 0, bottomPad: 0 });
|
||||
});
|
||||
|
||||
it("windows the middle of a long list with overscan", () => {
|
||||
// 100 items of 100px each, total 10000.
|
||||
const offsets = cumulativeOffsets(Array.from({ length: 100 }, () => 100));
|
||||
const win = computeWindow({
|
||||
offsets,
|
||||
scrollTop: 5000,
|
||||
viewportHeight: 500,
|
||||
overscan,
|
||||
});
|
||||
// windowTop = 4000, windowBottom = 6500.
|
||||
expect(win.start).toBe(40);
|
||||
expect(win.end).toBe(64);
|
||||
expect(win.topPad).toBe(4000);
|
||||
expect(win.bottomPad).toBe(10000 - 6500);
|
||||
});
|
||||
|
||||
it("clamps the top: scrollTop 0 gives start 0 and no top pad", () => {
|
||||
const offsets = cumulativeOffsets(Array.from({ length: 100 }, () => 100));
|
||||
const win = computeWindow({
|
||||
offsets,
|
||||
scrollTop: 0,
|
||||
viewportHeight: 500,
|
||||
overscan,
|
||||
});
|
||||
expect(win.start).toBe(0);
|
||||
expect(win.topPad).toBe(0);
|
||||
});
|
||||
|
||||
it("clamps the bottom: at max scroll, end is last and no bottom pad", () => {
|
||||
const offsets = cumulativeOffsets(Array.from({ length: 100 }, () => 100));
|
||||
const win = computeWindow({
|
||||
offsets,
|
||||
scrollTop: 10000 - 500,
|
||||
viewportHeight: 500,
|
||||
overscan,
|
||||
});
|
||||
expect(win.end).toBe(99);
|
||||
expect(win.bottomPad).toBe(0);
|
||||
});
|
||||
|
||||
it("returns an empty window for an empty list", () => {
|
||||
expect(
|
||||
computeWindow({
|
||||
offsets: [0],
|
||||
scrollTop: 0,
|
||||
viewportHeight: 500,
|
||||
overscan,
|
||||
}),
|
||||
).toEqual({ start: 0, end: -1, topPad: 0, bottomPad: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
// Pure layout math for the windowing renderer. Kept free of DOM and React so it
|
||||
// can be unit tested and reused by the container without re-rendering.
|
||||
|
||||
// cumulativeOffsets returns N+1 entries: the running top offset of each item,
|
||||
// with the final entry holding the total height.
|
||||
export function cumulativeOffsets(heights: readonly number[]): number[] {
|
||||
const offsets = new Array<number>(heights.length + 1);
|
||||
offsets[0] = 0;
|
||||
for (let i = 0; i < heights.length; i++) {
|
||||
offsets[i + 1] = offsets[i] + heights[i];
|
||||
}
|
||||
return offsets;
|
||||
}
|
||||
|
||||
type WindowInput = {
|
||||
offsets: readonly number[];
|
||||
scrollTop: number;
|
||||
viewportHeight: number;
|
||||
overscan: number;
|
||||
};
|
||||
|
||||
type Window = {
|
||||
start: number;
|
||||
end: number;
|
||||
topPad: number;
|
||||
bottomPad: number;
|
||||
};
|
||||
|
||||
// computeWindow returns the inclusive [start, end] item range to render plus the
|
||||
// spacer heights that reserve the unrendered ranges. The range covers the
|
||||
// viewport expanded by `overscan` on each side. An empty list yields an empty
|
||||
// range (end = -1) and zero pads.
|
||||
export function computeWindow({
|
||||
offsets,
|
||||
scrollTop,
|
||||
viewportHeight,
|
||||
overscan,
|
||||
}: WindowInput): Window {
|
||||
const count = offsets.length - 1;
|
||||
const total = offsets[count];
|
||||
if (count <= 0) {
|
||||
return { start: 0, end: -1, topPad: 0, bottomPad: 0 };
|
||||
}
|
||||
|
||||
const windowTop = scrollTop - overscan;
|
||||
const windowBottom = scrollTop + viewportHeight + overscan;
|
||||
|
||||
// First item whose bottom edge is past the window top.
|
||||
let start = 0;
|
||||
while (start < count - 1 && offsets[start + 1] <= windowTop) {
|
||||
start++;
|
||||
}
|
||||
|
||||
// Last item whose top edge is before the window bottom.
|
||||
let end = start;
|
||||
while (end < count - 1 && offsets[end + 1] < windowBottom) {
|
||||
end++;
|
||||
}
|
||||
|
||||
return {
|
||||
start,
|
||||
end,
|
||||
topPad: offsets[start],
|
||||
bottomPad: total - offsets[end + 1],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user