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();
// 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(
screen.getByRole("button", { name: "加载更早的消息(还剩 81 条)" }),
@@ -4524,6 +4524,53 @@ describe("App", () => {
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 () => {
render(<App />);
fireEvent.change(await screen.findByLabelText("向 GoodBuddy 提问"), {
+21 -15
View File
@@ -186,6 +186,7 @@ import {
messageRenderBatchSize,
type ChatScrollSnapshot,
} from "./ChatHistoryPane";
import { reconcilePaneOrder } from "./pane-order";
import { isUnusedConversation, sortConversationsForDisplay, type Conversation } from "./chat-conversation";
import {
ComposerDraftEffect,
@@ -3686,28 +3687,33 @@ function App(): React.JSX.Element {
);
// Kept panes must keep a stable DOM order. Reordering keyed siblings makes
// 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 conversationById = new Map(
conversations.map((conversation) => [conversation.id, conversation]),
);
const cachedIds = new Set(
[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) => {
return conversationPaneOrder.flatMap((conversationId) => {
const conversation = conversationById.get(conversationId);
return conversation ? [conversation] : [];
});
}, [activeId, cachedConversationViews, conversations]);
}, [conversationPaneOrder, conversations]);
const activeRuntimeResolution = useMemo(
() =>
+44 -21
View File
@@ -24,11 +24,16 @@ 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.
* fill the viewport, and renders the rest of its batch 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;
/**
* 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;
type ChatQuickAction = {
@@ -130,9 +135,9 @@ 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
// 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
// at once.
// at once. `undefined` means the full batch is rendered.
const [initialRenderLimit, setInitialRenderLimit] = useState<
number | undefined
>(() =>
@@ -143,14 +148,30 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
? initialMessageRenderCount
: 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<
{ scrollHeight: number; scrollTop: number } | undefined
>(undefined);
const initialRenderTarget = Math.min(
visibleMessageCount,
conversation.messages.length,
);
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).
if (steppingLimit === undefined) return;
// Grow by one step once the previous 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(() => {
@@ -161,18 +182,21 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
scrollTop: scrollContainer.scrollTop,
}
: undefined;
setInitialRenderLimit(undefined);
const nextLimit = steppingLimit + catchUpMessageRenderStep;
setInitialRenderLimit(
nextLimit >= initialRenderTarget ? undefined : nextLimit,
);
}, 0);
});
return () => {
cancelAnimationFrame(frame);
clearTimeout(timer);
};
}, [initialRenderLimit]);
}, [steppingLimit, initialRenderTarget]);
const renderedMessageCount =
initialRenderLimit === undefined
steppingLimit === undefined
? visibleMessageCount
: Math.min(visibleMessageCount, initialRenderLimit);
: Math.min(visibleMessageCount, steppingLimit);
const visibleMessageStartIndex = Math.max(
0,
conversation.messages.length - renderedMessageCount,
@@ -181,11 +205,11 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
() => conversation.messages.slice(visibleMessageStartIndex),
[conversation.messages, 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);
// The "load earlier" control reflects the real batch, not the partial step.
const hiddenMessageCount = Math.max(
0,
conversation.messages.length - visibleMessageCount,
);
useLayoutEffect(() => {
const context = contextRef.current;
@@ -377,10 +401,9 @@ 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.
// Each catch-up step 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;
+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;
}