From 55ebdd7a954235dbbccd73f26e71ab1559e53800 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 28 Oct 2026 11:25:43 +0800 Subject: [PATCH] Home first screen no longer carries the rectification surface or react-markdown (issue #124, BUG-1474) 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 --- CHANGELOG.md | 6 +++ docs/BUG_HISTORY.md | 15 ++++++ docs/testing/code-health-118-20261028.md | 6 +++ frontend/src/app/(app)/page.tsx | 2 +- .../src/components/chat-message-content.tsx | 20 ++------ ...sational-birth-time-rectification-lazy.tsx | 26 ++++++++++ .../src/hooks/use-rectification-surface.ts | 4 ++ frontend/src/lib/chat-markdown-loader.ts | 24 ++++++++++ frontend/src/lib/home-bootstrap-run.ts | 10 ++++ .../src/lib/rectification-surface-loader.ts | 30 ++++++++++++ .../chat-bundle-splitting-contract.test.ts | 12 +++-- frontend/tests/home-bootstrap-run.test.ts | 47 +++++++++++++++++++ .../home-lazy-rectification-20261028.test.ts | 46 ++++++++++++++++++ .../lagna-precheck-rectify-entry.test.tsx | 6 +++ 14 files changed, 234 insertions(+), 20 deletions(-) create mode 100644 frontend/src/components/conversational-birth-time-rectification-lazy.tsx create mode 100644 frontend/src/lib/chat-markdown-loader.ts create mode 100644 frontend/src/lib/rectification-surface-loader.ts create mode 100644 frontend/tests/home-lazy-rectification-20261028.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a8763b8..afd324b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # 印度占星 Skill 更新日志 +## 2026-10-28 — 首页第一次打开少下载约 95 KB + +- 首页第一次打开少下载约 95 KB(压缩后,约占原来的 13%):生时校正界面和回答排版组件等到用的时候才下载。 +- 进入生时校正、打开历史对话时照常一次出现,不会先空白或先出没排版的文字。 +- Skill 版本不变(BUG-1474,issue #124)。 + ## 2026-10-28 — 只知道出生时段时,咨询出字更快 - 只填了出生时段(没有精确到分钟)的人来咨询时,后台两次计算改成同时进行,每轮约快 0.3~1 秒。回答内容不变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 00fdac09..2883ee38 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -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`。 diff --git a/docs/testing/code-health-118-20261028.md b/docs/testing/code-health-118-20261028.md index c5a74039..69f3b044 100644 --- a/docs/testing/code-health-118-20261028.md +++ b/docs/testing/code-health-118-20261028.md @@ -23,3 +23,9 @@ 11. 电脑打开首页、右侧本命盘开着,问一个会写长回答的问题:回答照常逐字出来,右侧盘不闪、不跳;出字过程中拖动右侧盘的宽度、点盘上的宫位都跟手。 12. 回答还没出字时点「停止」:提示是「停止回答并申请退回本次点数」;出了一些字以后再问一次并点停止:提示变成「停止回答,保留已生成内容并退回本次点数」。 +## #124 首页少下载校正界面和排版组件(BUG-1474) + +13. 清掉浏览器缓存后打开首页:问候和输入框照常一次出来;问一个问题,回答里的加粗、列表照常有格式。 +14. 侧栏点一条以前的长对话(或直接打开带 `?c=` 的对话链接):一出现就是排好版的样子,不先闪一下纯文本。 +15. 从首页进入生时校正(说「帮我做生时纠正」,或从星盘页进入):校正界面一次出现,题目和进度都在,没有空白、没有转圈。 + diff --git a/frontend/src/app/(app)/page.tsx b/frontend/src/app/(app)/page.tsx index 4ca96d9c..a0845350 100644 --- a/frontend/src/app/(app)/page.tsx +++ b/frontend/src/app/(app)/page.tsx @@ -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"; diff --git a/frontend/src/components/chat-message-content.tsx b/frontend/src/components/chat-message-content.tsx index 737c2209..6f61050e 100644 --- a/frontend/src/components/chat-message-content.tsx +++ b/frontend/src/components/chat-message-content.tsx @@ -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 | 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); + const [renderer, setRenderer] = useState(() => loadedChatMarkdown()); useEffect(() => { if (renderer) return; let active = true; - void loadMarkdownRenderer().then((loaded) => { + void loadChatMarkdown().then((loaded) => { if (active) setRenderer(() => loaded); }); return () => { active = false; }; diff --git a/frontend/src/components/conversational-birth-time-rectification-lazy.tsx b/frontend/src/components/conversational-birth-time-rectification-lazy.tsx new file mode 100644 index 00000000..8c1dbed4 --- /dev/null +++ b/frontend/src/components/conversational-birth-time-rectification-lazy.tsx @@ -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 ? : null; +} diff --git a/frontend/src/hooks/use-rectification-surface.ts b/frontend/src/hooks/use-rectification-surface.ts index 8b2af1db..f2dcb741 100644 --- a/frontend/src/hooks/use-rectification-surface.ts +++ b/frontend/src/hooks/use-rectification-surface.ts @@ -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. diff --git a/frontend/src/lib/chat-markdown-loader.ts b/frontend/src/lib/chat-markdown-loader.ts new file mode 100644 index 00000000..4e059615 --- /dev/null +++ b/frontend/src/lib/chat-markdown-loader.ts @@ -0,0 +1,24 @@ +import type { ReactNode } from "react"; + +export type MarkdownRenderer = (text: string) => ReactNode; + +let markdownRenderer: MarkdownRenderer | undefined; +let markdownRendererRequest: Promise | 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 { + markdownRendererRequest ??= import("@/components/chat-markdown-view").then((module) => { + markdownRenderer = module.renderChatMarkdown; + return module.renderChatMarkdown; + }); + return markdownRendererRequest; +} diff --git a/frontend/src/lib/home-bootstrap-run.ts b/frontend/src/lib/home-bootstrap-run.ts index 0b74fe57..9385173d 100644 --- a/frontend/src/lib/home-bootstrap-run.ts +++ b/frontend/src/lib/home-bootstrap-run.ts @@ -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; + /** Markdown rendering for a landing conversation that has messages (issue #124). */ + preloadChatMarkdown: () => Promise; restoreConsultationRecovery: ( session: ChatSession, requestId: string, @@ -163,6 +166,7 @@ const defaultIo: Omit = { 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); diff --git a/frontend/src/lib/rectification-surface-loader.ts b/frontend/src/lib/rectification-surface-loader.ts new file mode 100644 index 00000000..e31fc6ce --- /dev/null +++ b/frontend/src/lib/rectification-surface-loader.ts @@ -0,0 +1,30 @@ +import type { ComponentType } from "react"; + +import type { ConversationalBirthTimeRectificationProps } from "@/components/conversational-birth-time-rectification"; + +type Surface = ComponentType; + +let surface: Surface | undefined; +let surfaceRequest: Promise | 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 { + 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; +} diff --git a/frontend/tests/chat-bundle-splitting-contract.test.ts b/frontend/tests/chat-bundle-splitting-contract.test.ts index e8688887..176a5a51 100644 --- a/frontend/tests/chat-bundle-splitting-contract.test.ts +++ b/frontend/tests/chat-bundle-splitting-contract.test.ts @@ -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\)/); diff --git a/frontend/tests/home-bootstrap-run.test.ts b/frontend/tests/home-bootstrap-run.test.ts index a100e53b..0b8db73a 100644 --- a/frontend/tests/home-bootstrap-run.test.ts +++ b/frontend/tests/home-bootstrap-run.test.ts @@ -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((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(() => 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)], diff --git a/frontend/tests/home-lazy-rectification-20261028.test.ts b/frontend/tests/home-lazy-rectification-20261028.test.ts new file mode 100644 index 00000000..7d9aa935 --- /dev/null +++ b/frontend/tests/home-lazy-rectification-20261028.test.ts @@ -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"); +}); diff --git a/frontend/tests/lagna-precheck-rectify-entry.test.tsx b/frontend/tests/lagna-precheck-rectify-entry.test.tsx index 38e1d671..c62a990a 100644 --- a/frontend/tests/lagna-precheck-rectify-entry.test.tsx +++ b/frontend/tests/lagna-precheck-rectify-entry.test.tsx @@ -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);