Home first screen no longer carries the rectification surface or react-markdown (issue #124, BUG-1474)
Independent Staging Quality Gate / publish (push) Canceled after 0s
Independent Staging Quality Gate / validate (push) Canceled after 9m27s

page.tsx imported the whole rectification surface statically; its verification
report also imported chat-markdown-view statically, which pulled react-markdown
into / even though chat-message-content already loaded it lazily.

- Home renders a same-named lazy wrapper; openRectificationCase starts the
  download with the open request and awaits it before setting the ids that
  mount the surface, so it still reveals once, drawn.
- The markdown loader moves to lib/chat-markdown-loader; the home bootstrap
  awaits it before revealing a conversation with messages (no plain-text flash
  on ?c=). The blank home does not wait.

/ first load, gzip-9 of every referenced JS/CSS: 704,165 -> 609,606 B (-13.4%),
still Static.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jesse_Chen
2026-10-28 11:25:43 +08:00
co-authored by Claude Opus 5.5
parent ea7dd95757
commit 55ebdd7a95
14 changed files with 234 additions and 20 deletions
+6
View File
@@ -1,5 +1,11 @@
# 印度占星 Skill 更新日志
## 2026-10-28 — 首页第一次打开少下载约 95 KB
- 首页第一次打开少下载约 95 KB(压缩后,约占原来的 13%):生时校正界面和回答排版组件等到用的时候才下载。
- 进入生时校正、打开历史对话时照常一次出现,不会先空白或先出没排版的文字。
- Skill 版本不变(BUG-1474,issue #124)。
## 2026-10-28 — 只知道出生时段时,咨询出字更快
- 只填了出生时段(没有精确到分钟)的人来咨询时,后台两次计算改成同时进行,每轮约快 0.3~1 秒。回答内容不变。
+15
View File
@@ -20111,3 +20111,18 @@
- 关联:BUG-957(窗口咨询预计算与缓存)。
- 复发自:无
- 修复版本:分支 `codex/window-consult-parallel-20261028`。
## BUG-1474 | 空白首页首屏带着整个生时校正界面和 Markdown 渲染
- 状态:resolved(代码 + 回归测试;待部署)
- 首次发现 / 最近更新:2026-10-28 / 2026-10-28(issue #118 代码体检第 14、15 条 → #124)
- 影响面:首页 `/` 首屏下载;`frontend/src/app/(app)/page.tsx`、`hooks/use-rectification-surface.ts`、`lib/home-bootstrap-run.ts`、`components/chat-message-content.tsx`。
- 现象:大多数人打开首页只是普通对话,却要先下载、解析整个生时校正界面(题池、精炼包、分歧面板、校正棋盘等)和 react-markdown 一整套。
- 触发条件:任何一次冷开首页。
- 根因:`page.tsx` 静态 import `ConversationalBirthTimeRectification`。`chat-message-content` 本来已经按需加载 Markdown,但校正界面里的 `rectification-verification-report.tsx` 静态 import 了 `chat-markdown-view`,于是 Markdown 也跟着校正界面进了首屏。
- 修复:首页改用 `conversational-birth-time-rectification-lazy.tsx`(同名组件,代码到手后同步渲染真组件);`lib/rectification-surface-loader.ts` 负责下载。`openRectificationCase` 在发开案请求时就开始下载,并和案情读取一起等它到齐后才设置会话 / 案号,所以校正界面仍是一次揭幕、不出现空白或转圈。Markdown 的加载挪到 `lib/chat-markdown-loader.ts`(组件和启动共用);首页启动落地到有消息(或可能有消息)的会话时,揭幕前等它到齐,`?c=` 打开带消息的会话不会先出纯文本再跳格式;空白首页不等。
- 验证:`next build` 后按首页预渲染 HTML 引用的全部 JS/CSS 做 gzip-9 合计:改前 704,165 B,改后 609,606 B(−94,559 B,−13.4%);JS 528,697 B,CSS 不变;`/` 仍 ○ Static;首屏块里不再有 micromark / mdast / remark 和校正界面模块。新测试 `frontend/tests/home-lazy-rectification-20261028.test.ts`(首页只经懒加载壳引用校正界面;开案时下载与开案请求同时开始、在设置会话之前等齐;加载器只下载一次);`home-bootstrap-run.test.ts` 新增两条(带消息的 `?c=` 会话在 Markdown 到齐前不提交;空白首页不等 Markdown;前一条在改前代码上失败)。`chat-bundle-splitting-contract.test.ts` 两处断言改认新的加载器文件;`lagna-precheck-rectify-entry.test.tsx` 两条打开测试先载入校正界面代码(tsx 现场转译超过原来 20 ms 的等待;测的仍是打开意图与会话)。全量前端测试与 staging 逐条比对无新增失败。tsc 0、lint 0 error(`page.tsx` 38 条 warning 与基线相同)。
- 防复发:上述测试。首页首屏只放普通对话必需的代码;进入某个功能才用的界面经加载器下载,并在打开流程里等齐再挂载。
- 关联:BUG-1130(城市表挪出首屏)、BUG-1469(#120 次级页同类问题)。
- 复发自:无
- 修复版本:分支 `codex/home-lazy-rectification-20261028`。
+6
View File
@@ -23,3 +23,9 @@
11. 电脑打开首页、右侧本命盘开着,问一个会写长回答的问题:回答照常逐字出来,右侧盘不闪、不跳;出字过程中拖动右侧盘的宽度、点盘上的宫位都跟手。
12. 回答还没出字时点「停止」:提示是「停止回答并申请退回本次点数」;出了一些字以后再问一次并点停止:提示变成「停止回答,保留已生成内容并退回本次点数」。
## #124 首页少下载校正界面和排版组件(BUG-1474)
13. 清掉浏览器缓存后打开首页:问候和输入框照常一次出来;问一个问题,回答里的加粗、列表照常有格式。
14. 侧栏点一条以前的长对话(或直接打开带 `?c=` 的对话链接):一出现就是排好版的样子,不先闪一下纯文本。
15. 从首页进入生时校正(说「帮我做生时纠正」,或从星盘页进入):校正界面一次出现,题目和进度都在,没有空白、没有转圈。
+1 -1
View File
@@ -22,7 +22,7 @@ import {
synastryReportClosed,
rectificationOrdinaryComposerReopen,
} from "@/lib/rectification-session-composer-guard";
import { ConversationalBirthTimeRectification } from "@/components/conversational-birth-time-rectification";
import { ConversationalBirthTimeRectification } from "@/components/conversational-birth-time-rectification-lazy";
import { declaredBirthTime } from "@/lib/rectification-surface-state";
import { toggleChatMessageFeedback } from "@/components/chat-message-actions";
import type { ChatTranscriptActions } from "@/components/chat-transcript";
@@ -6,6 +6,7 @@ import { prefetchOnIdle } from "@/components/chat-chunk-prefetch";
import { plainParagraphs } from "@/components/chat-message-paragraphs";
import { TechniqueAuditDisclosure } from "@/components/technique-audit-disclosure";
import { splitSpokenAndReport } from "@/lib/chat-answer-split";
import { loadChatMarkdown, loadedChatMarkdown, type MarkdownRenderer } from "@/lib/chat-markdown-loader";
import { splitStableMarkdown } from "@/lib/chat-markdown-split";
import type { TechniqueAuditRow } from "@/lib/consultation-agent-events";
import {
@@ -13,28 +14,15 @@ import {
splitSpokenAnswerAndTechniqueAudit,
} from "@/lib/consultation-technique-audit";
type MarkdownRenderer = (text: string) => ReactNode;
let markdownRenderer: MarkdownRenderer | undefined;
let markdownRendererRequest: Promise<MarkdownRenderer> | undefined;
function loadMarkdownRenderer() {
markdownRendererRequest ??= import("@/components/chat-markdown-view").then((module) => {
markdownRenderer = module.renderChatMarkdown;
return module.renderChatMarkdown;
});
return markdownRendererRequest;
}
prefetchOnIdle(loadMarkdownRenderer);
prefetchOnIdle(loadChatMarkdown);
function useMarkdownRenderer() {
const [renderer, setRenderer] = useState<MarkdownRenderer | undefined>(() => markdownRenderer);
const [renderer, setRenderer] = useState<MarkdownRenderer | undefined>(() => loadedChatMarkdown());
useEffect(() => {
if (renderer) return;
let active = true;
void loadMarkdownRenderer().then((loaded) => {
void loadChatMarkdown().then((loaded) => {
if (active) setRenderer(() => loaded);
});
return () => { active = false; };
@@ -0,0 +1,26 @@
"use client";
import { useEffect, useState } from "react";
import type { ConversationalBirthTimeRectificationProps } from "@/components/conversational-birth-time-rectification";
import { loadedRectificationSurface, loadRectificationSurface } from "@/lib/rectification-surface-loader";
/**
* Home renders this in place of the surface (issue #124). Every open path
* awaits `loadRectificationSurface` first, so the first render is already the
* real surface; the effect only covers a chunk that failed during the open.
*/
export function ConversationalBirthTimeRectification(props: ConversationalBirthTimeRectificationProps) {
const [Surface, setSurface] = useState(() => loadedRectificationSurface());
useEffect(() => {
if (Surface) return;
let active = true;
void loadRectificationSurface().then((loaded) => {
if (active) setSurface(() => loaded);
}).catch(() => undefined);
return () => { active = false; };
}, [Surface]);
return Surface ? <Surface {...props} /> : null;
}
@@ -45,6 +45,7 @@ import {
} from "@/lib/rectification-surface-state";
import { notifyError } from "@/lib/notify";
import { bindOfferToCase } from "@/lib/rectification-in-chat";
import { loadRectificationSurface } from "@/lib/rectification-surface-loader";
export type RectificationShellState = {
sessionId: string | null;
@@ -231,6 +232,8 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
rectificationOpenInFlight.current = true;
setRectificationLoading(true);
// The surface code downloads while the case opens (issue #124).
const surfaceCode = loadRectificationSurface().catch(() => null);
setRectificationError("");
setRectificationErrorSessionId(null);
// Read the selection source now: `selectSession` resets it synchronously
@@ -324,6 +327,7 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
timeoutMs: RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS,
});
const source = await sourceRead;
await surfaceCode;
if (sourceSessionId) {
// The server bound the offer card to this case; mirror it so the card
// under that answer stops offering a new start.
+24
View File
@@ -0,0 +1,24 @@
import type { ReactNode } from "react";
export type MarkdownRenderer = (text: string) => ReactNode;
let markdownRenderer: MarkdownRenderer | undefined;
let markdownRendererRequest: Promise<MarkdownRenderer> | undefined;
/** The renderer once its chunk has arrived, for a synchronous first render. */
export function loadedChatMarkdown(): MarkdownRenderer | undefined {
return markdownRenderer;
}
/**
* react-markdown and remark-gfm stay out of the first screen (issue #124).
* The home bootstrap awaits this before revealing a conversation that has
* messages, so a `?c=` landing never shows plain text first.
*/
export function loadChatMarkdown(): Promise<MarkdownRenderer> {
markdownRendererRequest ??= import("@/components/chat-markdown-view").then((module) => {
markdownRenderer = module.renderChatMarkdown;
return module.renderChatMarkdown;
});
return markdownRendererRequest;
}
+10
View File
@@ -64,6 +64,7 @@ import type { PublicLanguageModelCatalog } from "@/lib/public-models";
import { startRectificationEntrySummaryRead } from "@/lib/rectification-entry-summary-read";
import { takeBootRead } from "@/lib/boot-early-reads";
import { preloadHomeOnboardingShell } from "@/lib/home-onboarding-shell-loader";
import { loadChatMarkdown } from "@/lib/chat-markdown-loader";
import { dropRectificationStoredPending } from "@/lib/rectification-session-composer-guard";
import type { SessionListBoot } from "@/lib/session-list-context";
import { isUnsavedEmptyConsultation, replaceUnsavedEmptyConsultations } from "@/lib/session-list-filter";
@@ -111,6 +112,8 @@ export type HomeBootstrapIo = {
clearLoginSessionReturn: typeof clearLoginSessionReturn;
startEntrySummaryRead: () => void;
preloadOnboardingShell: () => Promise<void>;
/** Markdown rendering for a landing conversation that has messages (issue #124). */
preloadChatMarkdown: () => Promise<void>;
restoreConsultationRecovery: (
session: ChatSession,
requestId: string,
@@ -163,6 +166,7 @@ const defaultIo: Omit<HomeBootstrapIo, "restoreConsultationRecovery"> = {
clearLoginSessionReturn,
startEntrySummaryRead: () => startRectificationEntrySummaryRead(),
preloadOnboardingShell: preloadHomeOnboardingShell,
preloadChatMarkdown: () => loadChatMarkdown().then(() => undefined, () => undefined),
};
function resolveIo(deps: HomeBootstrapDeps): HomeBootstrapIo {
@@ -431,6 +435,11 @@ export async function resolveLanding(
}
const activeListed = nextSessions.find((session) => session.id === landingSessionId)
?? nextSessions[0];
// Markdown is not in the first screen (issue #124): a conversation that has
// or may have messages reveals already formatted, not plain text first.
const markdownReady = activeListed && (activeListed.messages.length > 0 || !activeListed.messagesHydrated)
? io.preloadChatMarkdown()
: null;
if (activeListed && !activeListed.messagesHydrated && activeListed.sessionType === "consultation") {
try {
const detailed = await io.fetchSessionDetail(activeListed.id, ready.nextModelCatalog, signal);
@@ -442,6 +451,7 @@ export async function resolveLanding(
if (caught instanceof LoginRedirectError) return;
}
}
if (markdownReady && nextSessions.find((session) => session.id === landingSessionId)?.messages.length) await markdownReady;
if (signal.aborted) return;
io.clearStaleClientReload(deps.storage);
deps.commit.setSessionsCursor(ready.cursor);
@@ -0,0 +1,30 @@
import type { ComponentType } from "react";
import type { ConversationalBirthTimeRectificationProps } from "@/components/conversational-birth-time-rectification";
type Surface = ComponentType<ConversationalBirthTimeRectificationProps>;
let surface: Surface | undefined;
let surfaceRequest: Promise<Surface> | undefined;
/** The surface once its chunk has arrived, for a synchronous first render. */
export function loadedRectificationSurface(): Surface | undefined {
return surface;
}
/**
* The rectification surface (and the markdown it reaches) is not in the home
* first screen (issue #124). Opening a case awaits this alongside the case
* hydration, so the surface still mounts once, already drawn.
*/
export function loadRectificationSurface(): Promise<Surface> {
surfaceRequest ??= import("@/components/conversational-birth-time-rectification").then((module) => {
surface = module.ConversationalBirthTimeRectification;
return surface;
}).catch((error: unknown) => {
// A failed chunk may succeed on the next open.
surfaceRequest = undefined;
throw error;
});
return surfaceRequest;
}
@@ -10,6 +10,7 @@ const markdownViewSource = readProjectFile("src/components/chat-markdown-view.ts
const messageRowSource = readProjectFile("src/components/chat-message-row.tsx");
const activitySource = readProjectFile("src/components/agent-activity-status.tsx");
const prefetchSource = readProjectFile("src/components/chat-chunk-prefetch.ts");
const markdownLoaderSource = readProjectFile("src/lib/chat-markdown-loader.ts");
test("the markdown pipeline is split out of the initial chat bundle", () => {
// Given: the chat surface statically imports the message content component.
@@ -17,8 +18,12 @@ test("the markdown pipeline is split out of the initial chat bundle", () => {
assert.doesNotMatch(contentSource, /^import .*"remark-gfm"/m);
// Then: the renderer only arrives through a dynamic import.
assert.match(contentSource, /import\("@\/components\/chat-markdown-view"\)/);
assert.match(contentSource, /markdownRenderer = module\.renderChatMarkdown/);
// 原值:chat-message-content.tsx 里直接 import("@/components/chat-markdown-view")
// 新值:动态 import 挪到 lib/chat-markdown-loader.ts,组件和首页启动共用这一份
// 原因:issue #124 首页启动要在揭幕前等 Markdown(`?c=` 打开带消息的会话)
assert.doesNotMatch(contentSource, /^import .*"@\/components\/chat-markdown-view"/m);
assert.match(markdownLoaderSource, /import\("@\/components\/chat-markdown-view"\)/);
assert.match(markdownLoaderSource, /markdownRenderer = module\.renderChatMarkdown/);
assert.match(markdownViewSource, /^import ReactMarkdown, \{ type Components \} from "react-markdown";$/m);
assert.match(markdownViewSource, /^import remarkGfm from "remark-gfm";$/m);
assert.match(markdownViewSource, /remarkPlugins=\{\[remarkGfm\]\}/);
@@ -105,7 +110,8 @@ test("deferred chat chunks are requested during idle time after first paint", ()
assert.match(prefetchSource, /if \(typeof window === "undefined"\) return;/);
assert.match(prefetchSource, /window\.requestIdleCallback\(request, \{ timeout: 2_000 \}\)/);
assert.match(prefetchSource, /window\.setTimeout\(request, 300\)/);
assert.match(contentSource, /prefetchOnIdle\(loadMarkdownRenderer\)/);
// 原值:prefetchOnIdle(loadMarkdownRenderer);新值与原因同上(issue #124)
assert.match(contentSource, /prefetchOnIdle\(loadChatMarkdown\)/);
// 原值: prefetchOnIdle(loadGsap) / 新值: prefetchOnIdle(loadMessageMotion)
// 原因: 同上,空闲预取的是 Motion 而不是 GSAP。
assert.match(messageRowSource, /if \(motionPreferred\(\)\) prefetchOnIdle\(loadMessageMotion\)/);
+47
View File
@@ -135,6 +135,9 @@ function createHarness(options: {
// 原因:TASK-home-first-load T7/T1 新增的 io 项默认会真的 import 引导页组件、发校正入口请求,
// 夹具的 waitUntil 只让 30 个微任务,真实动态 import 等不到;本组测的是启动时序,不是这两项。
preloadOnboardingShell: async () => undefined,
// 原值:夹具不提供 preloadChatMarkdown(io 当时没有这一项)。
// 新值:空操作;原因同上:默认会真的 import react-markdown(issue #124)。
preloadChatMarkdown: async () => undefined,
startEntrySummaryRead: () => undefined,
fetchConsultationStatus: async () => {
throw new Error("unexpected status read");
@@ -204,6 +207,50 @@ test("a successful bootstrap fills the home once, after every read has settled",
assert.ok(commits.indexOf("account") < commits.indexOf("phase"));
});
test("a ?c= conversation with messages reveals only after markdown has loaded (issue #124)", async () => {
const session = {
...createSession(previewModelCatalog.defaultModelId),
messages: [{ role: "user" as const, text: "这个月工作怎么样?" }, { role: "assistant" as const, text: "**稳住**节奏。" }],
messagesHydrated: true,
};
let preloads = 0;
let releaseMarkdown: () => void = () => undefined;
const markdownGate = new Promise<void>((resolve) => { releaseMarkdown = resolve; });
const { deps, state } = createHarness({
sessions: [session],
io: {
preloadChatMarkdown: () => { preloads += 1; return markdownGate; },
resolveLookupBootstrap: async (input) => ({
selection: { sessionId: session.id, urlAction: "keep", missing: false },
sessions: input.sessions,
notice: null,
}),
},
});
const pending = runHomeBootstrap(deps, new AbortController().signal);
await waitUntil(() => preloads === 1);
for (let tick = 0; tick < 30; tick += 1) await Promise.resolve();
assert.equal(state.sessions.length, 0, "the conversation is not committed before markdown arrives");
releaseMarkdown();
await pending;
assert.equal(state.active, session.id);
assert.equal(preloads, 1);
});
test("the blank starter home does not wait for markdown (issue #124)", async () => {
let preloads = 0;
const { deps, state } = createHarness({
sessions: [createSession(previewModelCatalog.defaultModelId)],
io: {
preloadChatMarkdown: () => { preloads += 1; return new Promise<void>(() => undefined); },
},
});
await runHomeBootstrap(deps, new AbortController().signal);
assert.ok(state.active, "the home was committed");
assert.ok(state.sessions.length > 0);
assert.equal(preloads, 0);
});
test("a model catalog failure stays on the fatal screen and does not half-reveal", async () => {
const { deps, state } = createHarness({
sessions: [createSession(previewModelCatalog.defaultModelId)],
@@ -0,0 +1,46 @@
/**
* Issue #124: the rectification surface and react-markdown are not in the
* home first screen. Opening a case still mounts the surface once, drawn.
*/
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
const page = read("../src/app/(app)/page.tsx");
const surfaceHook = read("../src/hooks/use-rectification-surface.ts");
const messageContent = read("../src/components/chat-message-content.tsx");
test("home imports the surface through the lazy wrapper only", () => {
assert.match(page, /from "@\/components\/conversational-birth-time-rectification-lazy"/);
assert.doesNotMatch(page, /from "@\/components\/conversational-birth-time-rectification"/);
// Nothing else the home reaches statically pulls in react-markdown.
assert.doesNotMatch(messageContent, /from "@\/components\/chat-markdown-view"/);
assert.match(messageContent, /loadChatMarkdown/);
});
test("opening a case awaits the surface code before the ids that mount it are set", () => {
const started = surfaceHook.indexOf("const surfaceCode = loadRectificationSurface()");
const awaited = surfaceHook.indexOf("await surfaceCode;");
const mounted = surfaceHook.indexOf("setRectificationSessionId(opened.sessionId);");
const opened = surfaceHook.indexOf('await fetch("/api/rectification/cases/open"');
assert.ok(started > 0 && started < opened, "the download starts with the open request");
assert.ok(awaited > opened && awaited < mounted, "the surface mounts only after its code is in");
});
test("the wrapper renders the real surface synchronously once it is loaded", async () => {
const { loadRectificationSurface, loadedRectificationSurface } = await import("../src/lib/rectification-surface-loader.ts");
assert.equal(loadedRectificationSurface(), undefined);
const loaded = await loadRectificationSurface();
assert.equal(loadedRectificationSurface(), loaded);
assert.equal(await loadRectificationSurface(), loaded, "one download per page");
const surfaceModule = await import("../src/components/conversational-birth-time-rectification.tsx");
assert.equal(loaded, surfaceModule.ConversationalBirthTimeRectification);
});
test("the markdown loader hands out one renderer", async () => {
const { loadChatMarkdown, loadedChatMarkdown } = await import("../src/lib/chat-markdown-loader.ts");
const renderer = await loadChatMarkdown();
assert.equal(loadedChatMarkdown(), renderer);
assert.equal(typeof renderer, "function");
});
@@ -14,6 +14,7 @@ import {
import type { Account, Profile } from "../src/lib/home-types.ts";
import type { PublicLanguageModelCatalog } from "../src/lib/public-models.ts";
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts";
import { loadRectificationSurface } from "../src/lib/rectification-surface-loader.ts";
Object.assign(globalThis, { React });
@@ -127,6 +128,11 @@ async function openFromChart(disposition: "resumed" | "created") {
const posts: Array<{ intent?: string }> = [];
const previousFetch = globalThis.fetch;
const harness = createClientLifecycleHarness();
// 原值:打开流程不等校正界面代码,两次 idle(约 20 ms)就设好会话
// 新值:先把校正界面代码载入,再走同样的打开流程
// 原因:issue #124 校正界面改为按需下载,打开时要等它到齐才设会话(一次揭幕);
// tsx 现场转译 74 个文件远超 20 ms,本组测的是打开意图与会话,不是下载时长
await loadRectificationSurface();
installLocation("?rectify=1");
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);