fix(webui): keep composer stable while scrolling

This commit is contained in:
chengyongru 2026-07-28 17:09:16 +08:00 committed by chengyongru
parent ae089aa3ae
commit 1faf0826f6
3 changed files with 134 additions and 51 deletions

View File

@ -174,7 +174,8 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
onQuoteSelection, onQuoteSelection,
}, ref) { }, ref) {
const { t } = useTranslation(); const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement | null>(null);
const viewportFrameRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null);
const messageRegionRef = useRef<HTMLDivElement>(null); const messageRegionRef = useRef<HTMLDivElement>(null);
const messageContentRef = useRef<HTMLDivElement>(null); const messageContentRef = useRef<HTMLDivElement>(null);
@ -236,6 +237,11 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
}); });
} }
const hasMessages = messages.length > 0; const hasMessages = messages.length > 0;
useLayoutEffect(() => {
scrollRef.current = hasMessages
? messageRegionRef.current
: viewportFrameRef.current;
}, [hasMessages]);
const visibleMessages = useMemo( const visibleMessages = useMemo(
() => windowMessages(messages, visibleMessageCount), () => windowMessages(messages, visibleMessageCount),
[messages, visibleMessageCount], [messages, visibleMessageCount],
@ -360,13 +366,13 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
useLayoutEffect(() => { useLayoutEffect(() => {
const updateKeyboardInset = () => { const updateKeyboardInset = () => {
const scrollEl = scrollRef.current; const composerDock = composerDockRef.current;
const next = readSoftKeyboardInsetBottom(scrollEl); const next = readSoftKeyboardInsetBottom(composerDock);
const active = document.activeElement; const active = document.activeElement;
const composerFocused = const composerFocused =
hasMessages hasMessages
&& isKeyboardEditableElement(active) && isKeyboardEditableElement(active)
&& Boolean(scrollEl?.contains(active)); && Boolean(composerDock?.contains(active));
setKeyboardInsetBottom((current) => setKeyboardInsetBottom((current) =>
Math.abs(current - next) < 1 ? current : next, Math.abs(current - next) < 1 ? current : next,
); );
@ -609,17 +615,22 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
el.removeEventListener("pointerdown", handlePointerDown); el.removeEventListener("pointerdown", handlePointerDown);
el.removeEventListener("keydown", handleKeyDown); el.removeEventListener("keydown", handleKeyDown);
}; };
}, [maybeLoadEarlierFromScroll, yieldCameraToUser]); }, [hasMessages, maybeLoadEarlierFromScroll, yieldCameraToUser]);
return ( return (
<div className="thread-viewport relative flex min-h-0 flex-1 overflow-hidden"> <div className="thread-viewport relative flex min-h-0 flex-1 overflow-hidden">
<div <div
ref={scrollRef} ref={viewportFrameRef}
className={cn( className={cn(
"thread-viewport-scrollbar absolute inset-0 scroll-auto", "thread-viewport-frame absolute inset-0",
"[overflow-anchor:none] [scrollbar-width:none]", hasMessages
"[&::-webkit-scrollbar]:hidden", ? "overflow-hidden"
hasVerticalOverflow ? "overflow-y-auto" : "overflow-hidden", : cn(
"thread-viewport-scrollbar scroll-auto",
"[overflow-anchor:none] [scrollbar-width:none]",
"[&::-webkit-scrollbar]:hidden",
hasVerticalOverflow ? "overflow-y-auto" : "overflow-hidden",
),
)} )}
style={scrollViewportStyle} style={scrollViewportStyle}
> >
@ -630,7 +641,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
className={cn( className={cn(
"thread-layout mx-auto grid min-h-full w-full", "thread-layout mx-auto grid min-h-full w-full",
hasMessages hasMessages
? "max-w-[64rem]" ? "h-full max-w-[64rem]"
: "max-w-[72rem] px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] pt-6 sm:px-4 sm:py-12", : "max-w-[72rem] px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] pt-6 sm:px-4 sm:py-12",
)} )}
> >
@ -638,7 +649,13 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
<div <div
ref={messageRegionRef} ref={messageRegionRef}
data-testid="thread-message-region" data-testid="thread-message-region"
className="row-start-1 flex min-h-0 min-w-0 flex-col justify-start px-3 pb-4 pt-4 sm:px-4" className={cn(
"thread-viewport-scrollbar row-start-1 flex min-h-0 min-w-0 flex-col",
"scroll-auto justify-start overflow-x-hidden px-3 pb-4 pt-4 sm:px-4",
"[overflow-anchor:none] [scrollbar-width:none]",
"[&::-webkit-scrollbar]:hidden",
hasVerticalOverflow ? "overflow-y-auto" : "overflow-hidden",
)}
> >
<div ref={messageContentRef} className="mx-auto w-full max-w-[49.5rem]"> <div ref={messageContentRef} className="mx-auto w-full max-w-[49.5rem]">
<ThreadMessages <ThreadMessages
@ -654,6 +671,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
onQuoteSelection={onQuoteSelection} onQuoteSelection={onQuoteSelection}
/> />
</div> </div>
<div ref={bottomRef} aria-hidden className="h-px shrink-0" />
</div> </div>
) : ( ) : (
<div className="row-start-1 flex min-h-0 min-w-0 w-full items-center justify-center sm:items-end sm:pb-8"> <div className="row-start-1 flex min-h-0 min-w-0 w-full items-center justify-center sm:items-end sm:pb-8">
@ -671,7 +689,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
}} }}
className={cn( className={cn(
"row-start-2 z-10 w-full", "row-start-2 z-10 w-full",
hasMessages ? "sticky bottom-0 bg-background" : "relative self-center", hasMessages ? "relative bg-background" : "relative self-center",
)} )}
> >
<div <div
@ -695,7 +713,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
className="thread-layout-spacer row-start-3 min-h-0 overflow-hidden" className="thread-layout-spacer row-start-3 min-h-0 overflow-hidden"
/> />
</div> </div>
<div ref={bottomRef} aria-hidden className="h-px" /> {!hasMessages ? <div ref={bottomRef} aria-hidden className="h-px" /> : null}
</div> </div>
<div <div

View File

@ -355,6 +355,9 @@
transition: transition:
grid-template-rows 900ms cubic-bezier(0.33, 1, 0.68, 1); grid-template-rows 900ms cubic-bezier(0.33, 1, 0.68, 1);
} }
.thread-layout[data-layout="thread"] {
grid-template-rows: minmax(0, 1fr) auto 0fr;
}
@media (min-width: 640px) { @media (min-width: 640px) {
.thread-layout[data-layout="hero"] { .thread-layout[data-layout="hero"] {
grid-template-rows: minmax(min-content, 1fr) auto 1fr; grid-template-rows: minmax(min-content, 1fr) auto 1fr;

View File

@ -148,6 +148,12 @@ function makePromptExchangeMessages(count: number): UIMessage[] {
])).flat(); ])).flat();
} }
function getScroller(container: HTMLElement): HTMLElement {
const scroller = container.querySelector<HTMLElement>(".thread-viewport-scrollbar");
if (!scroller) throw new Error("thread scrollport not found");
return scroller;
}
async function renderPromptRailViewport({ async function renderPromptRailViewport({
scrollTo, scrollTo,
}: { }: {
@ -162,7 +168,7 @@ async function renderPromptRailViewport({
/>, />,
); );
const scroller = container.firstElementChild?.firstElementChild as HTMLElement; const scroller = getScroller(container);
Object.defineProperties(scroller, { Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 1800 }, scrollHeight: { configurable: true, value: 1800 },
clientHeight: { configurable: true, value: 600 }, clientHeight: { configurable: true, value: 600 },
@ -260,6 +266,26 @@ describe("ThreadViewport", () => {
expect(screen.getByTestId("thread-composer-dock")).not.toHaveClass("mt-auto"); expect(screen.getByTestId("thread-composer-dock")).not.toHaveClass("mt-auto");
}); });
it("keeps the docked composer outside the message scrollport", () => {
const { container } = render(
<ThreadViewport
messages={messages}
isStreaming={false}
composer={<div>composer</div>}
/>,
);
const scroller = getScroller(container);
const messageRegion = screen.getByTestId("thread-message-region");
const composerDock = screen.getByTestId("thread-composer-dock");
expect(scroller).toBe(messageRegion);
expect(scroller).not.toContainElement(composerDock);
expect(scroller.parentElement).toContainElement(composerDock);
expect(composerDock).toHaveClass("relative");
expect(composerDock).not.toHaveClass("sticky");
expect(scroller.lastElementChild).toHaveClass("h-px", "shrink-0");
});
it("pins a waiting prompt to the exact lower scroll boundary", async () => { it("pins a waiting prompt to the exact lower scroll boundary", async () => {
const jumpTo = vi.spyOn(ThreadCameraController.prototype, "jumpTo"); const jumpTo = vi.spyOn(ThreadCameraController.prototype, "jumpTo");
const threaded: UIMessage[] = [ const threaded: UIMessage[] = [
@ -276,7 +302,7 @@ describe("ThreadViewport", () => {
/>, />,
); );
const scroller = container.firstElementChild?.firstElementChild as HTMLElement; const scroller = getScroller(container);
Object.defineProperties(scroller, { Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 1200 }, scrollHeight: { configurable: true, value: 1200 },
clientHeight: { configurable: true, value: 500 }, clientHeight: { configurable: true, value: 500 },
@ -323,7 +349,7 @@ describe("ThreadViewport", () => {
composer={<div>composer</div>} composer={<div>composer</div>}
/>, />,
); );
const scroller = container.firstElementChild?.firstElementChild as HTMLElement; const scroller = getScroller(container);
Object.defineProperties(scroller, { Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 1_200 }, scrollHeight: { configurable: true, value: 1_200 },
clientHeight: { configurable: true, value: 500 }, clientHeight: { configurable: true, value: 500 },
@ -367,6 +393,13 @@ describe("ThreadViewport", () => {
it("lets the first prompt supersede a pending empty-conversation camera command", async () => { it("lets the first prompt supersede a pending empty-conversation camera command", async () => {
const jumpTo = vi.spyOn(ThreadCameraController.prototype, "jumpTo"); const jumpTo = vi.spyOn(ThreadCameraController.prototype, "jumpTo");
const scrollTo = vi.fn(); const scrollTo = vi.fn();
const firstPrompt: UIMessage = {
id: "u-first",
role: "user",
content: "first question",
turnId: "turn-first",
createdAt: 1,
};
const { container, rerender } = render( const { container, rerender } = render(
<ThreadViewport <ThreadViewport
messages={emptyMessages} messages={emptyMessages}
@ -375,7 +408,7 @@ describe("ThreadViewport", () => {
conversationKey={null} conversationKey={null}
/>, />,
); );
const scroller = container.firstElementChild?.firstElementChild as HTMLElement; const scroller = getScroller(container);
Object.defineProperties(scroller, { Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 1200 }, scrollHeight: { configurable: true, value: 1200 },
clientHeight: { configurable: true, value: 500 }, clientHeight: { configurable: true, value: 500 },
@ -388,18 +421,32 @@ describe("ThreadViewport", () => {
await act(async () => { await act(async () => {
rerender( rerender(
<ThreadViewport <ThreadViewport
messages={[ messages={[firstPrompt]}
{
id: "u-first",
role: "user",
content: "first question",
turnId: "turn-first",
createdAt: 1,
},
]}
isStreaming isStreaming
composer={<div>composer</div>} composer={<div>composer</div>}
conversationKey="chat-a" conversationKey="chat-a"
conversationReady={false}
activeTurnId="turn-first"
activeTurnStartedHere
/>,
);
});
const threadScroller = getScroller(container);
Object.defineProperties(threadScroller, {
scrollHeight: { configurable: true, value: 1200 },
clientHeight: { configurable: true, value: 500 },
scrollTop: { configurable: true, writable: true, value: 0 },
scrollTo: { configurable: true, value: scrollTo },
});
jumpTo.mockClear();
await act(async () => {
rerender(
<ThreadViewport
messages={[firstPrompt]}
isStreaming
composer={<div>composer</div>}
conversationKey="chat-a"
conversationReady
activeTurnId="turn-first" activeTurnId="turn-first"
activeTurnStartedHere activeTurnStartedHere
/>, />,
@ -430,7 +477,7 @@ describe("ThreadViewport", () => {
/>, />,
); );
const scroller = container.firstElementChild?.firstElementChild as HTMLElement; const scroller = getScroller(container);
Object.defineProperties(scroller, { Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 1904 }, scrollHeight: { configurable: true, value: 1904 },
clientHeight: { configurable: true, value: 500 }, clientHeight: { configurable: true, value: 500 },
@ -494,7 +541,7 @@ describe("ThreadViewport", () => {
composer={<div>composer</div>} composer={<div>composer</div>}
/>, />,
); );
const scroller = container.firstElementChild?.firstElementChild as HTMLElement; const scroller = getScroller(container);
Object.defineProperties(scroller, { Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 2_000 }, scrollHeight: { configurable: true, value: 2_000 },
clientHeight: { configurable: true, value: 500 }, clientHeight: { configurable: true, value: 500 },
@ -603,7 +650,7 @@ describe("ThreadViewport", () => {
/>, />,
); );
const scroller = container.firstElementChild?.firstElementChild as HTMLElement; const scroller = getScroller(container);
Object.defineProperties(scroller, { Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 1904 }, scrollHeight: { configurable: true, value: 1904 },
clientHeight: { configurable: true, value: 500 }, clientHeight: { configurable: true, value: 500 },
@ -726,7 +773,7 @@ describe("ThreadViewport", () => {
composer={<div>composer</div>} composer={<div>composer</div>}
/>, />,
); );
const scroller = container.firstElementChild?.firstElementChild as HTMLElement; const scroller = getScroller(container);
Object.defineProperties(scroller, { Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 2400 }, scrollHeight: { configurable: true, value: 2400 },
clientHeight: { configurable: true, value: 600 }, clientHeight: { configurable: true, value: 600 },
@ -791,7 +838,7 @@ describe("ThreadViewport", () => {
/>, />,
); );
const scroller = container.firstElementChild?.firstElementChild as HTMLElement; const scroller = getScroller(container);
Object.defineProperties(scroller, { Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 1200 }, scrollHeight: { configurable: true, value: 1200 },
clientHeight: { configurable: true, value: 500 }, clientHeight: { configurable: true, value: 500 },
@ -886,7 +933,7 @@ describe("ThreadViewport", () => {
/> />
); );
const { container, rerender } = render(viewport(true)); const { container, rerender } = render(viewport(true));
const scroller = container.firstElementChild?.firstElementChild as HTMLElement; const scroller = getScroller(container);
Object.defineProperties(scroller, { Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 1_200 }, scrollHeight: { configurable: true, value: 1_200 },
clientHeight: { configurable: true, value: 500 }, clientHeight: { configurable: true, value: 500 },
@ -956,7 +1003,9 @@ describe("ThreadViewport", () => {
composer={<textarea aria-label="Message input" />} composer={<textarea aria-label="Message input" />}
/>, />,
); );
const scroller = container.firstElementChild?.firstElementChild as HTMLElement; const scroller = getScroller(container);
const viewportFrame = container.querySelector(".thread-viewport-frame");
expect(viewportFrame).not.toBeNull();
Object.defineProperties(scroller, { Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 2400 }, scrollHeight: { configurable: true, value: 2400 },
clientHeight: { configurable: true, value: 600 }, clientHeight: { configurable: true, value: 600 },
@ -973,13 +1022,13 @@ describe("ThreadViewport", () => {
fireEvent.focusIn(input); fireEvent.focusIn(input);
}); });
await waitFor(() => expect(scroller).toHaveStyle({ bottom: "320px" })); await waitFor(() => expect(viewportFrame).toHaveStyle({ bottom: "320px" }));
expect(screen.queryByRole("button", { name: "Scroll to bottom" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Scroll to bottom" })).not.toBeInTheDocument();
act(() => { act(() => {
visualViewport.viewport.dispatchEvent(new Event("resize")); visualViewport.viewport.dispatchEvent(new Event("resize"));
}); });
expect(scroller).toHaveStyle({ bottom: "320px" }); expect(viewportFrame).toHaveStyle({ bottom: "320px" });
} finally { } finally {
visualViewport.restore(); visualViewport.restore();
} }
@ -1112,7 +1161,7 @@ describe("ThreadViewport", () => {
composer={<textarea aria-label="Message input" />} composer={<textarea aria-label="Message input" />}
/>, />,
); );
const scroller = container.firstElementChild?.firstElementChild as HTMLElement; const scroller = getScroller(container);
Object.defineProperties(scroller, { Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 2400 }, scrollHeight: { configurable: true, value: 2400 },
clientHeight: { configurable: true, value: 600 }, clientHeight: { configurable: true, value: 600 },
@ -1146,7 +1195,7 @@ describe("ThreadViewport", () => {
composer={<textarea aria-label="Message input" />} composer={<textarea aria-label="Message input" />}
/>, />,
); );
const scroller = container.firstElementChild?.firstElementChild as HTMLElement; const scroller = getScroller(container);
Object.defineProperties(scroller, { Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 2400 }, scrollHeight: { configurable: true, value: 2400 },
clientHeight: { configurable: true, value: 600 }, clientHeight: { configurable: true, value: 600 },
@ -1182,7 +1231,7 @@ describe("ThreadViewport", () => {
showScrollToBottomButton={false} showScrollToBottomButton={false}
/>, />,
); );
const scroller = container.firstElementChild?.firstElementChild as HTMLElement; const scroller = getScroller(container);
Object.defineProperties(scroller, { Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 2400 }, scrollHeight: { configurable: true, value: 2400 },
clientHeight: { configurable: true, value: 600 }, clientHeight: { configurable: true, value: 600 },
@ -1224,7 +1273,7 @@ describe("ThreadViewport", () => {
/>, />,
); );
const scroller = container.firstElementChild?.firstElementChild as HTMLElement; const scroller = getScroller(container);
Object.defineProperties(scroller, { Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 2400 }, scrollHeight: { configurable: true, value: 2400 },
clientHeight: { configurable: true, value: 600 }, clientHeight: { configurable: true, value: 600 },
@ -1258,7 +1307,7 @@ describe("ThreadViewport", () => {
/>, />,
); );
const scroller = container.firstElementChild?.firstElementChild as HTMLElement; const scroller = getScroller(container);
Object.defineProperties(scroller, { Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 1800 }, scrollHeight: { configurable: true, value: 1800 },
clientHeight: { configurable: true, value: 600 }, clientHeight: { configurable: true, value: 600 },
@ -1414,7 +1463,7 @@ describe("ThreadViewport", () => {
/>, />,
); );
const scroller = container.firstElementChild?.firstElementChild as HTMLElement; const scroller = getScroller(container);
Object.defineProperties(scroller, { Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 700 }, scrollHeight: { configurable: true, value: 700 },
clientHeight: { configurable: true, value: 600 }, clientHeight: { configurable: true, value: 600 },
@ -1452,7 +1501,7 @@ describe("ThreadViewport", () => {
/>, />,
); );
const scroller = container.firstElementChild?.firstElementChild as HTMLElement; const scroller = getScroller(container);
const scrollTo = vi.fn(); const scrollTo = vi.fn();
Object.defineProperties(scroller, { Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 10000 }, scrollHeight: { configurable: true, value: 10000 },
@ -1536,7 +1585,7 @@ describe("ThreadViewport", () => {
conversationKey="chat-a" conversationKey="chat-a"
/>, />,
); );
const scroller = container.firstElementChild?.firstElementChild as HTMLElement; const scroller = getScroller(container);
Object.defineProperties(scroller, { Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 2400 }, scrollHeight: { configurable: true, value: 2400 },
clientHeight: { configurable: true, value: 600 }, clientHeight: { configurable: true, value: 600 },
@ -1597,7 +1646,7 @@ describe("ThreadViewport", () => {
activeTurnId="old-turn" activeTurnId="old-turn"
/>, />,
); );
const scroller = container.firstElementChild?.firstElementChild as HTMLElement; const scroller = getScroller(container);
Object.defineProperties(scroller, { Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 2400 }, scrollHeight: { configurable: true, value: 2400 },
clientHeight: { configurable: true, value: 600 }, clientHeight: { configurable: true, value: 600 },
@ -1650,7 +1699,7 @@ describe("ThreadViewport", () => {
conversationKey={null} conversationKey={null}
/>, />,
); );
const scroller = container.firstElementChild?.firstElementChild as HTMLElement; const scroller = getScroller(container);
Object.defineProperties(scroller, { Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 0 }, scrollHeight: { configurable: true, value: 0 },
clientHeight: { configurable: true, value: 600 }, clientHeight: { configurable: true, value: 600 },
@ -1670,9 +1719,21 @@ describe("ThreadViewport", () => {
); );
expect(jumpTo).toHaveBeenCalledWith(0); expect(jumpTo).toHaveBeenCalledWith(0);
Object.defineProperty(scroller, "scrollHeight", { rerender(
configurable: true, <ThreadViewport
value: 2400, messages={messages}
isStreaming={false}
composer={<div />}
conversationKey="chat-a"
conversationReady={false}
/>,
);
const hydratedScroller = getScroller(container);
Object.defineProperties(hydratedScroller, {
scrollHeight: { configurable: true, value: 2400 },
clientHeight: { configurable: true, value: 600 },
scrollTop: { configurable: true, writable: true, value: 0 },
scrollTo: { configurable: true, value: scrollTo },
}); });
scrollTo.mockClear(); scrollTo.mockClear();
jumpTo.mockClear(); jumpTo.mockClear();
@ -1683,10 +1744,11 @@ describe("ThreadViewport", () => {
isStreaming={false} isStreaming={false}
composer={<div />} composer={<div />}
conversationKey="chat-a" conversationKey="chat-a"
conversationReady
/>, />,
); );
await waitFor(() => expect(scroller.scrollTop).toBe(1800)); await waitFor(() => expect(hydratedScroller.scrollTop).toBe(1800));
expect(jumpTo).toHaveBeenCalledWith(1800); expect(jumpTo).toHaveBeenCalledWith(1800);
}); });
@ -1700,7 +1762,7 @@ describe("ThreadViewport", () => {
scrollToBottomSignal={0} scrollToBottomSignal={0}
/>, />,
); );
const scroller = container.firstElementChild?.firstElementChild as HTMLElement; const scroller = getScroller(container);
Object.defineProperties(scroller, { Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 2400 }, scrollHeight: { configurable: true, value: 2400 },
clientHeight: { configurable: true, value: 600 }, clientHeight: { configurable: true, value: 600 },