perf: catch up new conversation panes in small steps and keep pane order in state

The first message batch now grows by 20 per frame after the first paint instead of one large commit, keeping the reader at the bottom or in place. The cached pane order is reconciled into state by a pure helper instead of reading and writing a ref during render (react-hooks/refs).
This commit is contained in:
mesalogo
2026-10-02 23:11:11 +08:00
parent a1a1d24022
commit 855b941aeb
5 changed files with 165 additions and 37 deletions
+48 -1
View File
@@ -4475,7 +4475,7 @@ describe("App", () => {
expect(await screen.findByText("历史消息 160")).toBeInTheDocument(); expect(await screen.findByText("历史消息 160")).toBeInTheDocument();
// The first paint shows only the trailing messages; the rest of the batch // The first paint shows only the trailing messages; the rest of the batch
// follows right after. // follows in steps.
expect(container.querySelectorAll(".message").length).toBeLessThanOrEqual(20); expect(container.querySelectorAll(".message").length).toBeLessThanOrEqual(20);
expect( expect(
screen.getByRole("button", { name: "加载更早的消息(还剩 81 条)" }), screen.getByRole("button", { name: "加载更早的消息(还剩 81 条)" }),
@@ -4524,6 +4524,53 @@ describe("App", () => {
expect(screen.getByText("历史消息 000").closest("article")).toHaveFocus(); expect(screen.getByText("历史消息 000").closest("article")).toHaveFocus();
}); });
it("catches up the first message batch in several small steps", async () => {
vi.mocked(api.conversations.list).mockResolvedValueOnce([
{
id: "00000000-0000-4000-8000-000000000422",
projectId,
title: "分步渲染会话",
updatedAt: 1_775_000_000_000,
messages: Array.from({ length: 161 }, (_, index) => ({
id: `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`,
role: index % 2 === 0 ? ("user" as const) : ("assistant" as const),
content: `分步消息 ${String(index).padStart(3, "0")}`,
createdAt: 1_775_000_000_000 + index,
state: "complete" as const,
})),
},
]);
const { container } = render(<App />);
const counts: number[] = [];
const record = (): void => {
const count = container.querySelectorAll(".message").length;
if (count > 0 && counts.at(-1) !== count) counts.push(count);
};
const observer = new MutationObserver(record);
observer.observe(container, { childList: true, subtree: true });
try {
expect(await screen.findByText("分步消息 160")).toBeInTheDocument();
record();
await waitFor(() =>
expect(container.querySelectorAll(".message")).toHaveLength(80),
);
record();
} finally {
observer.disconnect();
}
expect(counts[0]).toBeLessThanOrEqual(20);
expect(counts.at(-1)).toBe(80);
// More than one catch-up commit, each adding at most 20 messages.
expect(counts.filter((count) => count > 20 && count < 80).length).toBeGreaterThan(0);
counts.slice(1).forEach((count, index) => {
expect(count - (counts[index] ?? 0)).toBeLessThanOrEqual(20);
});
expect(
screen.getByRole("button", { name: "加载更早的消息(还剩 81 条)" }),
).toBeInTheDocument();
});
it("keeps the reader position while a response continues below", async () => { it("keeps the reader position while a response continues below", async () => {
render(<App />); render(<App />);
fireEvent.change(await screen.findByLabelText("向 GoodBuddy 提问"), { fireEvent.change(await screen.findByLabelText("向 GoodBuddy 提问"), {
+21 -15
View File
@@ -186,6 +186,7 @@ import {
messageRenderBatchSize, messageRenderBatchSize,
type ChatScrollSnapshot, type ChatScrollSnapshot,
} from "./ChatHistoryPane"; } from "./ChatHistoryPane";
import { reconcilePaneOrder } from "./pane-order";
import { isUnusedConversation, sortConversationsForDisplay, type Conversation } from "./chat-conversation"; import { isUnusedConversation, sortConversationsForDisplay, type Conversation } from "./chat-conversation";
import { import {
ComposerDraftEffect, ComposerDraftEffect,
@@ -3686,28 +3687,33 @@ function App(): React.JSX.Element {
); );
// Kept panes must keep a stable DOM order. Reordering keyed siblings makes // Kept panes must keep a stable DOM order. Reordering keyed siblings makes
// React detach and reinsert scroll containers, which resets their scrollTop. // React detach and reinsert scroll containers, which resets their scrollTop.
const conversationPaneOrderRef = useRef<string[]>([]); // The order lives in state and is reconciled during render; the helper
// returns the previous array when nothing changed, so this settles at once.
const cachedConversationIds = useMemo(
() =>
[activeId, ...cachedConversationViews.map((entry) => entry.key)].filter(
(id): id is string => Boolean(id),
),
[activeId, cachedConversationViews],
);
const [conversationPaneOrderState, setConversationPaneOrderState] =
useState<readonly string[]>([]);
const conversationPaneOrder = reconcilePaneOrder(
conversationPaneOrderState,
cachedConversationIds,
);
if (conversationPaneOrder !== conversationPaneOrderState) {
setConversationPaneOrderState(conversationPaneOrder);
}
const cachedConversations = useMemo(() => { const cachedConversations = useMemo(() => {
const conversationById = new Map( const conversationById = new Map(
conversations.map((conversation) => [conversation.id, conversation]), conversations.map((conversation) => [conversation.id, conversation]),
); );
const cachedIds = new Set( return conversationPaneOrder.flatMap((conversationId) => {
[activeId, ...cachedConversationViews.map((entry) => entry.key)].filter(
Boolean,
),
);
const paneOrder = conversationPaneOrderRef.current.filter((id) =>
cachedIds.has(id),
);
cachedIds.forEach((id) => {
if (!paneOrder.includes(id)) paneOrder.push(id);
});
conversationPaneOrderRef.current = paneOrder;
return paneOrder.flatMap((conversationId) => {
const conversation = conversationById.get(conversationId); const conversation = conversationById.get(conversationId);
return conversation ? [conversation] : []; return conversation ? [conversation] : [];
}); });
}, [activeId, cachedConversationViews, conversations]); }, [conversationPaneOrder, conversations]);
const activeRuntimeResolution = useMemo( const activeRuntimeResolution = useMemo(
() => () =>
+44 -21
View File
@@ -24,11 +24,16 @@ import type { TimeFormatLocale } from "./time-format";
export const messageRenderBatchSize = 80; export const messageRenderBatchSize = 80;
/** /**
* A freshly mounted pane first renders only this many trailing messages, which * 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 * fill the viewport, and renders the rest of its batch after the first paint.
* paint. Opening a long conversation then costs roughly a quarter of * Opening a long conversation then costs roughly a quarter of the Markdown and
* the Markdown and layout work before anything is shown. * layout work before anything is shown.
*/ */
export const initialMessageRenderCount = 20; export const initialMessageRenderCount = 20;
/**
* The rest of the batch is caught up in steps of this many messages, one step
* per frame, so no single catch-up commit becomes a long frame.
*/
export const catchUpMessageRenderStep = 20;
const chatBottomProximity = 96; const chatBottomProximity = 96;
type ChatQuickAction = { type ChatQuickAction = {
@@ -130,9 +135,9 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
const [showScrollToBottom, setShowScrollToBottom] = useState( const [showScrollToBottom, setShowScrollToBottom] = useState(
scrollSnapshot ? !scrollSnapshot.pinnedToBottom : false, scrollSnapshot ? !scrollSnapshot.pinnedToBottom : false,
); );
// Only a pane that opens at the bottom renders in two steps. A saved scroll // Only a pane that opens at the bottom renders in steps. A saved scroll
// position or a note target refers to the full batch, so those render it // position or a note target refers to the full batch, so those render it
// at once. // at once. `undefined` means the full batch is rendered.
const [initialRenderLimit, setInitialRenderLimit] = useState< const [initialRenderLimit, setInitialRenderLimit] = useState<
number | undefined number | undefined
>(() => >(() =>
@@ -143,14 +148,30 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
? initialMessageRenderCount ? initialMessageRenderCount
: undefined, : undefined,
); );
const [steppedVisibleMessageCount] = useState(visibleMessageCount);
// Stepping only covers the batch the pane opened with. A changed batch
// ("load earlier", note navigation) or a note target for this pane ends the
// stepping and renders the full batch at once. Adjusting state during
// render avoids committing a partial step first.
const stopStepping =
initialRenderLimit !== undefined &&
(visibleMessageCount !== steppedVisibleMessageCount ||
noteMessageNavigation?.conversationId === conversation.id);
if (stopStepping) setInitialRenderLimit(undefined);
const steppingLimit = stopStepping ? undefined : initialRenderLimit;
const initialRenderScrollRef = useRef< const initialRenderScrollRef = useRef<
{ scrollHeight: number; scrollTop: number } | undefined { scrollHeight: number; scrollTop: number } | undefined
>(undefined); >(undefined);
const initialRenderTarget = Math.min(
visibleMessageCount,
conversation.messages.length,
);
useEffect(() => { useEffect(() => {
if (initialRenderLimit === undefined) return; if (steppingLimit === undefined) return;
// Render the rest once the first step has been painted. A plain update, // Grow by one step once the previous step has been painted. A plain
// not a transition: streaming rows commit synchronously many times per // update, not a transition: streaming rows commit synchronously many times
// second and would keep restarting a transition (see live-message-store). // per second and would keep restarting a transition (see
// live-message-store).
let timer: ReturnType<typeof setTimeout> | undefined; let timer: ReturnType<typeof setTimeout> | undefined;
const frame = requestAnimationFrame(() => { const frame = requestAnimationFrame(() => {
timer = setTimeout(() => { timer = setTimeout(() => {
@@ -161,18 +182,21 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
scrollTop: scrollContainer.scrollTop, scrollTop: scrollContainer.scrollTop,
} }
: undefined; : undefined;
setInitialRenderLimit(undefined); const nextLimit = steppingLimit + catchUpMessageRenderStep;
setInitialRenderLimit(
nextLimit >= initialRenderTarget ? undefined : nextLimit,
);
}, 0); }, 0);
}); });
return () => { return () => {
cancelAnimationFrame(frame); cancelAnimationFrame(frame);
clearTimeout(timer); clearTimeout(timer);
}; };
}, [initialRenderLimit]); }, [steppingLimit, initialRenderTarget]);
const renderedMessageCount = const renderedMessageCount =
initialRenderLimit === undefined steppingLimit === undefined
? visibleMessageCount ? visibleMessageCount
: Math.min(visibleMessageCount, initialRenderLimit); : Math.min(visibleMessageCount, steppingLimit);
const visibleMessageStartIndex = Math.max( const visibleMessageStartIndex = Math.max(
0, 0,
conversation.messages.length - renderedMessageCount, conversation.messages.length - renderedMessageCount,
@@ -181,11 +205,11 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
() => conversation.messages.slice(visibleMessageStartIndex), () => conversation.messages.slice(visibleMessageStartIndex),
[conversation.messages, visibleMessageStartIndex], [conversation.messages, visibleMessageStartIndex],
); );
// The "load earlier" control reflects the real batch, not the first step. // The "load earlier" control reflects the real batch, not the partial step.
const hiddenMessageCount = const hiddenMessageCount = Math.max(
initialRenderLimit === undefined 0,
? visibleMessageStartIndex conversation.messages.length - visibleMessageCount,
: Math.max(0, conversation.messages.length - visibleMessageCount); );
useLayoutEffect(() => { useLayoutEffect(() => {
const context = contextRef.current; const context = contextRef.current;
@@ -377,10 +401,9 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
} }
}, [visibleMessageCount]); }, [visibleMessageCount]);
// The rest of the first batch lands above what the reader sees: stay at the // Each catch-up step lands above what the reader sees: stay at the bottom,
// bottom, or keep the reader's place if they already scrolled up. // or keep the reader's place if they already scrolled up.
useLayoutEffect(() => { useLayoutEffect(() => {
if (initialRenderLimit !== undefined) return;
const previous = initialRenderScrollRef.current; const previous = initialRenderScrollRef.current;
initialRenderScrollRef.current = undefined; initialRenderScrollRef.current = undefined;
const scrollContainer = scrollRef.current; const scrollContainer = scrollRef.current;
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { reconcilePaneOrder } from "./pane-order";
describe("reconcilePaneOrder", () => {
it("appends new ids in order", () => {
expect(reconcilePaneOrder([], ["a", "b"])).toEqual(["a", "b"]);
expect(reconcilePaneOrder(["a", "b"], ["c", "a", "b"])).toEqual(["a", "b", "c"]);
});
it("keeps the relative position of existing ids regardless of input order", () => {
expect(reconcilePaneOrder(["a", "b", "c"], ["c", "b", "a"])).toEqual(["a", "b", "c"]);
});
it("drops removed ids", () => {
expect(reconcilePaneOrder(["a", "b", "c"], ["c", "a", "d"])).toEqual(["a", "c", "d"]);
});
it("returns the same array when the order is unchanged", () => {
const previous = ["a", "b"];
expect(reconcilePaneOrder(previous, new Set(["b", "a"]))).toBe(previous);
const empty: string[] = [];
expect(reconcilePaneOrder(empty, [])).toBe(empty);
});
it("ignores duplicate ids", () => {
expect(reconcilePaneOrder([], ["a", "a", "b"])).toEqual(["a", "b"]);
});
});
+24
View File
@@ -0,0 +1,24 @@
/**
* Reconciles a stable order for kept-alive panes. Ids already in `previous`
* keep their relative position, ids missing from `ids` are dropped and new ids
* are appended in iteration order. Returns `previous` itself when nothing
* changed, so it is safe to store the result in state during render.
*/
export function reconcilePaneOrder(
previous: readonly string[],
ids: Iterable<string>,
): readonly string[] {
const wanted = new Set(ids);
const next = previous.filter((id) => wanted.has(id));
const kept = new Set(next);
wanted.forEach((id) => {
if (!kept.has(id)) next.push(id);
});
if (
next.length === previous.length &&
next.every((id, index) => id === previous[index])
) {
return previous;
}
return next;
}