Home and the chat chart no longer re-render on every streamed frame (issue #123, BUG-1472)
Independent Staging Quality Gate / publish (push) Canceled after 0s
Independent Staging Quality Gate / validate (push) Canceled after 2m12s

Home subscribed to the whole streamingReply object, so each animation frame of
an answer re-ran the page (header, picker, composer, overlays) and the desktop
natal panel under it. ChatStreamingTranscript now subscribes for its session
and feeds ChatTranscript; Home reads one derived boolean for the stop label.
ChatChartSplit is memoized. 100 frames: Home <=2 commits (was >=100), panel 0.

Two source-contract tests now pin the new component instead of the old names.
The onboarding typewriter still ticks at Home level (out of this ticket).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jesse_Chen
2026-10-28 11:13:55 +08:00
co-authored by Claude Opus 5.5
parent 9a0ce76d48
commit ad801a5db7
9 changed files with 233 additions and 31 deletions
+5
View File
@@ -1,5 +1,10 @@
# 印度占星 Skill 更新日志
## 2026-10-28 — 回答出字时页面不再整页重画
- 回答逐字出来时,只有回答那一块在更新;顶栏、输入框和右侧本命盘不再每一帧跟着重画(以前最多每秒 60 次)。长回答时电脑更省力,滚动和点按更跟手。看到的内容不变。
- Skill 版本不变(BUG-1472,issue #123)。
## 2026-10-28 — 个人长报告生成快约四分之一
- 第一次生成一份完整的个人长报告,本机实测从约 26 秒降到约 20 秒;报告内容一字不变。
+16
View File
@@ -20080,3 +20080,19 @@
- 复发自:无
- 修复版本:分支 `codex/report-fast-default-20261028`。
## BUG-1472 | 回答出字时首页整页和右侧本命盘每帧重画
- 状态:resolved(代码 + 回归测试;待部署)
- 首次发现 / 最近更新:2026-10-28 / 2026-10-28(issue #118 代码体检第 13 条 → #123)
- 影响面:首页对话(`frontend/src/app/(app)/page.tsx` `Home`)、桌面端默认打开的本命盘面板(`chat-chart-split.tsx`)。
- 现象:回答逐字出来时,整个首页(顶栏、人物选择、余额、输入框、模型选择等)和右侧整张星盘跟着每一帧重画,最多 60 次/秒;一条 1,500 字的回答约 375 次。外观和内容没有变化,只是白耗主线程。
- 触发条件:已登录首页,任意一次流式回答;桌面端本命盘面板打开时更重。
- 根因:`Home` 用 `useShallow` 订阅了整个 `streamingReply` 对象,流式缓冲每帧提交一个新对象,`Home` 整体重跑;`ChatChartSplit` 没有 `memo`,跟着 `ChatChartFrame` 的 children 变化重画。BUG-1329 只修了侧栏重注册,`Home` 自身的每帧重渲染留着。
- 修复:新增 `components/chat-streaming-transcript.tsx`。`ChatStreamingTranscript` 自己按会话订阅流式回复并喂给 `ChatTranscript`;`Home` 只订阅派生布尔值 `useStreamingReplyHasText`(停止按钮文案用,一次回答最多变两次)。`ChatChartSplit` 包 `memo`(props 都是 store 值)。`page.tsx` 少 19 行,未新增 useState / useRef。
- 验证:新测试 `frontend/tests/home-stream-rerender-20261028.test.tsx`:100 帧真实写 store,旧接法 Home 提交 ≥100 次;新接法 Home 自身提交 ≤2 次,面板 0 次,正文仍逐帧更新并显示完整;停止文案在出字前 / 后正确切换;源码断言 `page.tsx` 不再读 `streamingReply`、`ChatChartSplit` 是 memo。改前代码上源码断言失败。tsc 0、相关文件 lint 0。
- 防复发:上述测试。流式状态只让消息区订阅;页面级组件只订阅会变得很少的派生值。
- 未做:新手引导打字机(`setPresetMessageLength` 每 26 ms 一次)仍在 Home 顶层。它被 `use-profile-onboarding` 的 8 处重置驱动,其中几处是「同一句话重放」,下放需要改重置语义,超出本单验收;只在建档那几秒发生。
- 关联:BUG-1329(侧栏每帧重注册)、BUG-1130。
- 复发自:无
- 修复版本:分支 `codex/home-stream-rerender-20261028`。
+5
View File
@@ -18,3 +18,8 @@
9. 点盘型条右边的 ≡:「星盘类型」标题在左、叉号在右,同一行;电脑上整块面板完整可见。
10. 手机聊天页点开盘面:顶边没有白线,盘离屏幕两边更近。
## #123 回答出字时页面不再整页重画(BUG-1472)
11. 电脑打开首页、右侧本命盘开着,问一个会写长回答的问题:回答照常逐字出来,右侧盘不闪、不跳;出字过程中拖动右侧盘的宽度、点盘上的宫位都跟手。
12. 回答还没出字时点「停止」:提示是「停止回答并申请退回本次点数」;出了一些字以后再问一次并点停止:提示变成「停止回答,保留已生成内容并退回本次点数」。
+6 -25
View File
@@ -25,7 +25,8 @@ import {
import { ConversationalBirthTimeRectification } from "@/components/conversational-birth-time-rectification";
import { declaredBirthTime } from "@/lib/rectification-surface-state";
import { toggleChatMessageFeedback } from "@/components/chat-message-actions";
import { ChatTranscript, type ChatTranscriptActions } from "@/components/chat-transcript";
import type { ChatTranscriptActions } from "@/components/chat-transcript";
import { ChatStreamingTranscript, useStreamingReplyHasText } from "@/components/chat-streaming-transcript";
import { ChatProfilePicker } from "@/components/chat-profile-picker";
import { ChatChartFrame, ChatChartToggle } from "@/components/chat-chart-frame";
import { ModelSelector } from "@/components/model-selector";
@@ -244,7 +245,6 @@ export default function Home() {
setPendingSessionId,
pendingRequestId,
setPendingRequestId,
streamingReply,
setStreamingReply,
messageFeedback,
setMessageFeedback,
@@ -262,7 +262,6 @@ export default function Home() {
setPendingSessionId: state.setPendingSessionId,
pendingRequestId: state.pendingRequestId,
setPendingRequestId: state.setPendingRequestId,
streamingReply: state.streamingReply,
setStreamingReply: state.setStreamingReply,
messageFeedback: state.messageFeedback,
setMessageFeedback: state.setMessageFeedback,
@@ -421,19 +420,7 @@ export default function Home() {
|| rectificationMutationPending
|| !account
|| !modelCatalog;
const activeStreamingText = streamingReply && streamingReply.sessionId === activeSession?.id ? streamingReply.text : "";
const activeStreamingActivity = streamingReply && streamingReply.sessionId === activeSession?.id
? streamingReply.activity
: undefined;
const activeStreamingThinking = streamingReply && streamingReply.sessionId === activeSession?.id
? streamingReply.thinkingText
: undefined;
const activeStreamingSections = streamingReply && streamingReply.sessionId === activeSession?.id
? streamingReply.thinkingSections
: undefined;
const activeStreamingTimeline = streamingReply && streamingReply.sessionId === activeSession?.id
? streamingReply.timeline
: undefined;
const activeStreamingHasText = useStreamingReplyHasText(activeSession?.id);
const activeReplyOutcome = replyOutcome && replyOutcome.sessionId === activeSession?.id ? replyOutcome : null;
const replyPhase: ChatReplyPhase = isLoading
? consultationPhase === "recovering" ? "recovering" : "generating"
@@ -1319,15 +1306,9 @@ export default function Home() {
</div>
) : (
<div className="message-list" aria-busy={isLoading}>
<ChatTranscript
<ChatStreamingTranscript
messages={activeSession.messages}
loading={isLoading}
streamingText={activeStreamingText}
streamingActivity={activeStreamingActivity}
streamingThinking={activeStreamingThinking}
streamingSections={activeStreamingSections}
streamingTimeline={activeStreamingTimeline}
streamingResponseKind={streamingReply?.sessionId === activeSession?.id ? streamingReply?.responseKind : undefined}
sessionId={activeSession.id}
sessionType={activeSession.sessionType}
theme={activeSession.theme}
@@ -1382,8 +1363,8 @@ export default function Home() {
submitLabel={!synastryChat && !profileComplete && shownOnboardingStep === "name" ? "确认称呼" : "发送"}
submitBlocked={synastryClosed || subjectDeleted || Boolean(pendingSessionId) || Boolean(queuedDraft) || sessionMessagesLoading || rectificationSurfaceOpen || activeRectificationSession || !account || !modelCatalog || (!synastryChat && !profileComplete && (shownOnboardingStep !== "name" || !presetMessageFinished || profileSaving))}
stopVisible={isLoading}
stopLabel={consultationPhase === "undo" ? "撤回发送,本次不扣点" : activeStreamingText ? "停止回答,保留已生成内容并退回本次点数" : "停止回答并申请退回本次点数"}
stopTitle={consultationPhase === "undo" ? "撤回发送,本次不扣点" : activeStreamingText ? "停止回答,保留现有内容并申请退回本次点数" : "停止回答"}
stopLabel={consultationPhase === "undo" ? "撤回发送,本次不扣点" : activeStreamingHasText ? "停止回答,保留已生成内容并退回本次点数" : "停止回答并申请退回本次点数"}
stopTitle={consultationPhase === "undo" ? "撤回发送,本次不扣点" : activeStreamingHasText ? "停止回答,保留现有内容并申请退回本次点数" : "停止回答"}
onSubmit={submit}
onChange={(event) => {
setDraft(event.target.value);
+6 -3
View File
@@ -1,5 +1,7 @@
"use client";
import { memo } from "react";
import { ChatNatalChart } from "@/components/chat-natal-chart";
import { Button } from "@/components/ui/button";
import { useDragWidth } from "@/hooks/use-drag-width";
@@ -8,9 +10,10 @@ import { CHAT_CHART_WIDTH_MAX, CHAT_CHART_WIDTH_MIN } from "@/lib/chat-chart-pan
/**
* The right-hand natal column. The chat column stays outside it, so loading this
* never remounts the chat (BUG-1448). Its left edge drags or takes the arrow
* keys; dragging below the minimum closes the panel (BUG-1456).
* keys; dragging below the minimum closes the panel (BUG-1456). Memoized: its
* props are store values, so a Home render alone never redraws the chart (issue #123).
*/
export function ChatChartSplit({
export const ChatChartSplit = memo(function ChatChartSplit({
open,
width,
subjectId,
@@ -45,4 +48,4 @@ export function ChatChartSplit({
<ChatNatalChart subjectId={subjectId} />
</aside>
);
}
});
@@ -0,0 +1,37 @@
"use client";
import { ChatTranscript, type ChatTranscriptProps } from "@/components/chat-transcript";
import type { StreamingReply } from "@/lib/home-types";
import { useSessionStore } from "@/stores/session-store";
type StreamingFields = "streamingText" | "streamingActivity" | "streamingThinking" | "streamingSections" | "streamingTimeline" | "streamingResponseKind";
function replyFor(state: { streamingReply: StreamingReply | null }, sessionId: string | undefined) {
const reply = state.streamingReply;
return reply && reply.sessionId === sessionId ? reply : null;
}
/**
* The transcript is the only subscriber to the streaming reply (issue #123).
* The stream commits once per animation frame; when Home subscribed, every
* frame re-rendered the whole page and the desktop chart panel with it.
*/
export function ChatStreamingTranscript(props: Omit<ChatTranscriptProps, StreamingFields>) {
const reply = useSessionStore((state) => replyFor(state, props.sessionId));
return (
<ChatTranscript
{...props}
streamingText={reply?.text ?? ""}
streamingActivity={reply?.activity}
streamingThinking={reply?.thinkingText}
streamingSections={reply?.thinkingSections}
streamingTimeline={reply?.timeline}
streamingResponseKind={reply?.responseKind}
/>
);
}
/** Whether the active reply has text yet; flips at most twice per answer, so Home can read it. */
export function useStreamingReplyHasText(sessionId: string | undefined) {
return useSessionStore((state) => Boolean(replyFor(state, sessionId)?.text));
}
@@ -161,7 +161,10 @@ test("the streaming reply announces state transitions, never streamed deltas", (
// And: the old start-only region that lived inside the busy message list is gone.
assert.doesNotMatch(pageSource, /<span className="sr-only" aria-live="polite">\{isLoading \? "Jyotisha 正在回答" : ""\}<\/span>/);
assert.match(pageSource, /<div className="message-list" aria-busy=\{isLoading\}>\n\s*<ChatTranscript/);
// 原值:忙碌列表里直接是 `<ChatTranscript`
// 新值:`<ChatStreamingTranscript`,它自己订阅流式回复再渲染 ChatTranscript
// 原因:issue #123(BUG-1472)Home 不再随每帧流式重画
assert.match(pageSource, /<div className="message-list" aria-busy=\{isLoading\}>\n\s*<ChatStreamingTranscript/);
});
test("the reply phase covers start, completion and every terminal state", () => {
+7 -2
View File
@@ -10,6 +10,7 @@ const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.
const messageRowSource = readFileSync(new URL("../src/components/chat-message-row.tsx", import.meta.url), "utf8");
const activitySource = readFileSync(new URL("../src/components/agent-activity-status.tsx", import.meta.url), "utf8");
const timelineSource = readFileSync(new URL("../src/components/consultation-run-timeline.tsx", import.meta.url), "utf8");
const streamingTranscriptSource = readFileSync(new URL("../src/components/chat-streaming-transcript.tsx", import.meta.url), "utf8");
const reducerSource = readFileSync(new URL("../src/lib/consultation-run-timeline.ts", import.meta.url), "utf8");
const previousMessages = [
@@ -123,7 +124,10 @@ test("shows honest agent activity states before and during streamed text", () =>
assert.match(pageSource, /createNdjsonParser/);
assert.match(pageSource, /event.type === "thinking.section"/);
assert.match(pageSource, /event.type === "thinking.delta"/);
assert.match(pageSource, /activeStreamingSections/);
// 原值:page.tsx 里有 `activeStreamingSections`
// 新值:流式的思考分节由 ChatStreamingTranscript 自己订阅后传给 ChatTranscript
// 原因:issue #123(BUG-1472)Home 不再订阅流式回复,避免每帧整页重画
assert.match(streamingTranscriptSource, /streamingSections=\{reply\?\.thinkingSections\}/);
assert.match(pageSource, /streamedThinking/);
assert.match(messageRowSource, /showLiveActivity/);
// Former locks on `ConsultationThinkingReport`, `hasTrace`, `activityTrace={message.activityTrace}`,
@@ -145,7 +149,8 @@ test("shows honest agent activity states before and during streamed text", () =>
assert.match(pageSource, /\.\.\.\(thinkingSections\.length \? \{ thinkingSections \} : \{\}\)/);
assert.match(pageSource, /reduceConsultationTimeline/);
assert.match(pageSource, /timeline: timelineState\.rows/);
assert.match(pageSource, /activeStreamingTimeline/);
// 原值:page.tsx 里有 `activeStreamingTimeline`;新值与原因同上(issue #123)
assert.match(streamingTranscriptSource, /streamingTimeline=\{reply\?\.timeline\}/);
assert.match(messageRowSource, /ConsultationRunTimeline/);
assert.match(messageRowSource, /message\.timeline/);
assert.match(timelineSource, /BookOpen/);
@@ -0,0 +1,147 @@
/**
* Issue #123: while an answer streams, only the transcript follows the reply.
* Home used to subscribe to `streamingReply`, so every frame re-rendered the
* whole page and the desktop chart panel under it.
*/
import "./install-test-document";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { createRef, memo, Profiler, useEffect, type ProfilerOnRenderCallback, type ReactNode } from "react";
import { ChatChartSplit } from "../src/components/chat-chart-split.tsx";
import { ChatStreamingTranscript, useStreamingReplyHasText } from "../src/components/chat-streaming-transcript.tsx";
import { ChatTranscript, type ChatTranscriptActions, type ChatTranscriptProps } from "../src/components/chat-transcript.tsx";
import type { ChatMessage } from "../src/lib/chat-message-view.ts";
import { useSessionStore } from "../src/stores/session-store.ts";
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts";
const SESSION = "session-stream";
const FRAMES = 100;
const messages: ChatMessage[] = [{ role: "user", text: "这个月工作上要注意什么?" }];
function transcriptProps(): Omit<ChatTranscriptProps, "streamingText"> {
const actionsRef = createRef<ChatTranscriptActions>() as ChatTranscriptProps["actionsRef"];
actionsRef.current = { onFeedback() {}, onCopy() {}, onRegenerate() {}, onFollowUp() {}, onStartRectification() {} };
return {
messages,
loading: true,
sessionId: SESSION,
sessionType: "consultation",
theme: "career",
messageFeedback: {},
copiedMessageKey: null,
cancellationPending: false,
productEntrypointsDisabled: true,
actionsRef,
};
}
// Stands in for the chart drawing: counts its own renders without fetching a chart.
const panelRenders = { count: 0 };
const PanelStandIn = memo(function PanelStandIn({ subjectId }: { subjectId: string }) {
useEffect(() => { panelRenders.count += 1; });
return <aside data-subject={subjectId} />;
});
async function streamFrames(Page: () => ReactNode) {
const harness = createClientLifecycleHarness();
const commits = { home: 0 };
const onRender: ProfilerOnRenderCallback = (id) => { if (id === "home") commits.home += 1; };
useSessionStore.getState().setStreamingReply({ sessionId: SESSION, text: "" });
try {
await harness.render(<Profiler id="home" onRender={onRender}><Page /></Profiler>);
await harness.idle();
assert.equal(harness.errors.length, 0);
commits.home = 0;
panelRenders.count = 0;
let text = "";
for (let frame = 1; frame <= FRAMES; frame += 1) {
text += "字字";
await harness.update(() => useSessionStore.getState().setStreamingReply({ sessionId: SESSION, text }));
}
const shown = harness.container.text.includes(text);
return { homeCommits: commits.home, panelRenders: panelRenders.count, transcriptShowsText: shown };
} finally {
await harness.close();
useSessionStore.getState().setStreamingReply(null);
}
}
function HomeBefore() {
// The pre-#123 wiring: Home subscribes to the whole reply object.
const reply = useSessionStore((state) => state.streamingReply);
const text = reply?.sessionId === SESSION ? reply.text : "";
return (
<>
<PanelStandIn subjectId="self" />
<span data-stop={text ? "keep" : "refund"} />
<ChatTranscript {...transcriptProps()} streamingText={text} />
</>
);
}
function HomeAfter() {
const hasText = useStreamingReplyHasText(SESSION);
return (
<>
<PanelStandIn subjectId="self" />
<span data-stop={hasText ? "keep" : "refund"} />
<ChatStreamingTranscript {...transcriptProps()} />
</>
);
}
test("100 streamed frames: the old wiring re-rendered Home every frame, the panel now not at all", async () => {
const before = await streamFrames(HomeBefore);
const after = await streamFrames(HomeAfter);
assert.ok(before.homeCommits >= FRAMES, `the old wiring committed Home ${before.homeCommits} times`);
// The Profiler counts its subtree, so the transcript's own per-frame commits land
// under "home" as well; Home's own renders are counted in the next test.
assert.equal(after.panelRenders, 0, `panel rendered ${after.panelRenders} times`);
assert.equal(after.transcriptShowsText, true, "the streamed text is still on screen");
assert.equal(before.transcriptShowsText, true);
});
test("Home renders at most twice while the transcript follows every frame", async () => {
const counts = { home: 0, transcript: 0 };
const onTranscript: ProfilerOnRenderCallback = () => { counts.transcript += 1; };
function Page() {
const hasText = useStreamingReplyHasText(SESSION);
// A dependency-free effect runs once per commit of this component itself.
useEffect(() => { counts.home += 1; });
return (
<>
<span data-stop={hasText ? "keep" : "refund"} />
<Profiler id="transcript" onRender={onTranscript}><ChatStreamingTranscript {...transcriptProps()} /></Profiler>
</>
);
}
await streamFrames(Page);
// Subtract the mount (one commit each).
const home = counts.home - 1;
assert.ok(home <= 2, `Home rendered ${home} times over ${FRAMES} frames`);
assert.ok(home <= FRAMES / 10);
assert.ok(counts.transcript - 1 >= FRAMES, "the transcript still repaints every frame");
});
test("the stop label still knows whether text has arrived", async () => {
const seen: boolean[] = [];
function Probe() {
seen.push(useStreamingReplyHasText(SESSION));
return null;
}
await streamFrames(Probe);
assert.equal(seen[0], false);
assert.equal(seen.at(-1), true);
assert.ok(seen.length <= 3, `probe rendered ${seen.length} times`);
});
test("Home no longer subscribes to the streaming reply, and the chart split is memoized", () => {
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
assert.doesNotMatch(page, /state\.streamingReply\b/);
assert.doesNotMatch(page, /\bstreamingReply\b(?!:)/);
assert.match(page, /<ChatStreamingTranscript\b/);
assert.match(page, /useStreamingReplyHasText\(activeSession\?\.id\)/);
assert.equal((ChatChartSplit as unknown as { $$typeof: symbol }).$$typeof, Symbol.for("react.memo"));
});