perf: render a newly opened conversation in two steps

A freshly mounted pane first renders the trailing 20 messages and the rest of its 80-message batch right after the first paint, keeping the bottom or the reader's place. Saved scroll positions and note targets still render the full batch at once. Keeps the write-on-change guard for --chat-context-height.
This commit is contained in:
mesalogo
2026-10-02 20:06:44 +08:00
parent e97263c79e
commit 09c415bd41
2 changed files with 80 additions and 3 deletions
+9 -1
View File
@@ -4474,8 +4474,16 @@ describe("App", () => {
const { container } = render(<App />);
expect(await screen.findByText("历史消息 160")).toBeInTheDocument();
// The first paint shows only the trailing messages; the rest of the batch
// follows right after.
expect(container.querySelectorAll(".message").length).toBeLessThanOrEqual(20);
expect(
screen.getByRole("button", { name: "加载更早的消息(还剩 81 条)" }),
).toBeInTheDocument();
await waitFor(() =>
expect(container.querySelectorAll(".message")).toHaveLength(80),
);
expect(screen.queryByText("历史消息 080")).not.toBeInTheDocument();
expect(container.querySelectorAll(".message")).toHaveLength(80);
const chat = container.querySelector<HTMLElement>(".chat");
if (!chat) {
throw new Error("Missing chat scroll container");
+71 -2
View File
@@ -22,6 +22,13 @@ import { isUnusedConversation, type Conversation } from "./chat-conversation";
import type { TimeFormatLocale } from "./time-format";
export const messageRenderBatchSize = 80;
/**
* A freshly mounted pane first renders only this many trailing messages, which
* fill the viewport, and renders the rest of its batch right after the first
* paint. Opening a long conversation then costs roughly a quarter of
* the Markdown and layout work before anything is shown.
*/
export const initialMessageRenderCount = 20;
const chatBottomProximity = 96;
type ChatQuickAction = {
@@ -123,15 +130,62 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
const [showScrollToBottom, setShowScrollToBottom] = useState(
scrollSnapshot ? !scrollSnapshot.pinnedToBottom : false,
);
// Only a pane that opens at the bottom renders in two steps. A saved scroll
// position or a note target refers to the full batch, so those render it
// at once.
const [initialRenderLimit, setInitialRenderLimit] = useState<
number | undefined
>(() =>
(scrollSnapshot === undefined || scrollSnapshot.pinnedToBottom) &&
noteMessageNavigation?.conversationId !== conversation.id &&
Math.min(visibleMessageCount, conversation.messages.length) >
initialMessageRenderCount
? initialMessageRenderCount
: undefined,
);
const initialRenderScrollRef = useRef<
{ scrollHeight: number; scrollTop: number } | undefined
>(undefined);
useEffect(() => {
if (initialRenderLimit === undefined) return;
// Render the rest once the first step has been painted. A plain update,
// not a transition: streaming rows commit synchronously many times per
// second and would keep restarting a transition (see live-message-store).
let timer: ReturnType<typeof setTimeout> | undefined;
const frame = requestAnimationFrame(() => {
timer = setTimeout(() => {
const scrollContainer = scrollRef.current;
initialRenderScrollRef.current = scrollContainer
? {
scrollHeight: scrollContainer.scrollHeight,
scrollTop: scrollContainer.scrollTop,
}
: undefined;
setInitialRenderLimit(undefined);
}, 0);
});
return () => {
cancelAnimationFrame(frame);
clearTimeout(timer);
};
}, [initialRenderLimit]);
const renderedMessageCount =
initialRenderLimit === undefined
? visibleMessageCount
: Math.min(visibleMessageCount, initialRenderLimit);
const visibleMessageStartIndex = Math.max(
0,
conversation.messages.length - visibleMessageCount,
conversation.messages.length - renderedMessageCount,
);
const visibleMessages = useMemo(
() => conversation.messages.slice(visibleMessageStartIndex),
[conversation.messages, visibleMessageStartIndex],
);
const hiddenMessageCount = visibleMessageStartIndex;
// The "load earlier" control reflects the real batch, not the first step.
const hiddenMessageCount =
initialRenderLimit === undefined
? visibleMessageStartIndex
: Math.max(0, conversation.messages.length - visibleMessageCount);
useLayoutEffect(() => {
const context = contextRef.current;
@@ -323,7 +377,22 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
}
}, [visibleMessageCount]);
// The rest of the first batch lands above what the reader sees: stay at the
// bottom, or keep the reader's place if they already scrolled up.
useLayoutEffect(() => {
if (initialRenderLimit !== undefined) return;
const previous = initialRenderScrollRef.current;
initialRenderScrollRef.current = undefined;
const scrollContainer = scrollRef.current;
if (!previous || !scrollContainer) return;
scrollContainer.scrollTop = pinnedToBottomRef.current
? scrollContainer.scrollHeight
: previous.scrollTop +
(scrollContainer.scrollHeight - previous.scrollHeight);
}, [initialRenderLimit]);
const revealEarlierMessages = useCallback((): void => {
setInitialRenderLimit(undefined);
const scrollContainer = scrollRef.current;
if (scrollContainer) {
prependScrollPositionRef.current = {