fix(chat): reveal the home page once after a two-phase bootstrap

The loading screen now owns every wait: starter questions, today's
starlanguage and the rectification entry summary are fetched in a prepare
phase before reveal (4s budget, static fallbacks), the rectification chunk is
warmed, recent sessions are prefetched, and no component spinner remains
after the page appears. BUG-479.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
This commit is contained in:
Jesse_Chen
2026-09-02 04:39:04 +00:00
co-authored by Claude Fable 5.1
parent 7557e0d2ed
commit 482796fc52
10 changed files with 305 additions and 39 deletions
+46
View File
@@ -0,0 +1,46 @@
# PROGRESS · 首页加载统一:一次等待、一次揭幕(2026-09-02)
分支:`codex/unified-loading-20260902`
基线:`origin/staging` @ `02f06255`,并先行合入 `codex/streaming-ux-20260901` @ `c846c44a`(任务书前置条件;合并无冲突,merge commit `25e3094f`)。本轮改动写在合并后的代码形态上。
执行方:Claude(用户 2026-09-02 明示"直接开始执行")。
## 结论
登录后只有一次揭幕。加载屏两阶段(载入账户 → 准备对话),阶段二并行取推荐问题、今日星语、校正入口摘要并预热校正分包;全部就绪或 4 秒到期才揭幕;揭幕后首页无任何组件级 spinner / busy / "正在…"文案(流式生成中除外)。
## 与任务书的差异
- **当前会话消息**:合入的流式分支已把 `fetchSessionDetail` 前移到 `setHydrated` 之前(`page.tsx` 启动 effect),本轮不再重复做,只把它计入"阶段二"的语义说明。
- **阶段二实现方式**:没有把三个请求搬进启动 effect 的 `Promise.allSettled`,而是把三个既有 effect 的门槛从 `hydrated` 改为 `bootstrapPhase`(`"account" → "prepare"`),再用一个揭幕 effect 汇总就绪状态。理由:三个 effect 各自带重试/身份守卫/缓存逻辑(推荐问题的 `onboardingRequestIdentity`、今日星语的当日缓存与一次重试),原样复用不会产生重复请求,也不必复制这些逻辑。合同测试锁 `bootstrapPhase === "account" || !accountId` 门槛恰为 3 处。
- **今日星语的 pending 文案**:任务书写"非个性化文案";实际采用与 unavailable 相同的静态句「今天的星语还没写出来。」,到达后静默替换。原「正在写下今天的星语。」删除。
## 改动清单
| 文件 | 内容 |
| --- | --- |
| `src/lib/home-bootstrap.ts`(新) | `BootstrapPhase`、`BOOTSTRAP_PREPARE_TIMEOUT_MS = 4000`、`SESSION_PREFETCH_COUNT = 5`、`bootstrapPrepareSettled`、`bootstrapRevealDelayMs`、`bootstrapLoadingCopy`、`sessionIdsToPrefetch` |
| `src/app/page.tsx` | `bootstrapPhase` / `rectificationEntrySummarySettled` state、`prepareStartedAt` ref;`finally` 里成功路径改 `setBootstrapPhase("prepare")`(失败仍 `setHydrated(true)`);三个 effect 门槛与依赖改为 `bootstrapPhase`;入口摘要 effect 加 `finally` 置 settled;新增揭幕 effect(就绪或超时)、分包预热 effect、揭幕后预取 effect;加载屏文案按阶段;删 `starter-loading` 分支、`dailyStarlanguageBusy`、`InlineSpinner` import 与会话切换 spinner |
| `src/components/starter-home.tsx` | 删 `dailyStarlanguageBusy` prop 与 `aria-busy` |
| `src/app/globals.css` | 删两处 `.starter-loading` 定义(已无使用) |
| `DESIGN.md` §9 | 整页阻塞行改为"两阶段载入";补首页揭幕规则段 |
| `tests/home-bootstrap-reveal.test.ts`(新) | 7 条:纯函数 + 源码合同 |
| `tests/daily-starlanguage.test.ts` | 例外条款:effect 切片标记改为新门槛与新依赖(原值 `!hydrated ...` / `[..., hydrated, ...]`);`aria-busy={dailyStarlanguageBusy}` 正断言改为不存在(原值见注释) |
| `tests/starter-questions.test.ts` | 例外条款:守卫切片终点由 `"(onboardingPending ?"` 改为 `"<StarterHome"`(原值见注释) |
| `docs/BUG_HISTORY.md` | BUG-479 |
`onboardingPending` 变量保留(滚动 effect 与 `productEntrypointsDisabled` 仍用),只是不再驱动渲染分支。
## 验证(本机,无 Docker)
- `./node_modules/.bin/tsc --noEmit`:exit 0。
- `npm run lint`:0 error(72 warning,全部既有;本轮改动文件无新增 warning)。
- `npm test`:2493 条,pass 2459 / fail 24 / skipped 10;24 条失败清单与 `551d6317` 基线**逐字相同**(数据库/部署类既有环境缺口),无新增。
- Python quick-gate 前端契约四文件:24 passed。
- `next build`(默认 Turbopack):exit 0,`┌ ○ /` Static。
- 首屏 JS gzip-9(预渲染 `index.html` 引用的 `/_next/static/**.js` 去重后求和):合并点 `25e3094f` 基线 513,570 B → 本轮 513,889 B,**+319 B / +0.06%**(±2% 内)。
## 未做 / 待用户
- 无登录态:登录后单次揭幕、Slow 3G 下 4 秒兜底揭幕、切换会话零 spinner 均未在浏览器实测,由合同测试覆盖。建议照 `docs/testing/staging-manual-walkthrough-20260901.md` 加一条:DevTools 节流 Slow 3G 刷新首页,加载屏应出现"正在准备对话"文案,最多约 4 秒后一次进入完整界面,进入后无任何转圈。
- 本分支包含 `codex/streaming-ux-20260901` 的合并;合入 staging 时会一并带入该分支(其独立验收见该轮记录)。
+16
View File
@@ -7362,3 +7362,19 @@
- 相关记录:BUG-477
- 复发自:无
- 修复版本:待发布
## BUG-479 | 首页揭幕后仍有组件级加载:外层加载屏只等三样,推荐问题、今日星语、校正摘要各自转圈
- 状态:resolved
- 首次发现:2026-09-02
- 最近更新:2026-09-02
- 影响面:`page.tsx` 启动 effect 与揭幕条件、`starter-home.tsx`、`globals.css`(`.starter-loading`)、`lib/home-bootstrap.ts`(新增)、`DESIGN.md` §9
- 用户现象:登录后先看一次整页加载屏,进入界面后推荐问题位置再转一次(`starter-loading`)、今日星语卡 `aria-busy` 并显示「正在写下今天的星语。」、切会话消息区出现 spinner;用户感受是"进来了又在等"。
- 触发条件:任何一次进入首页;切换到消息尚未缓存的会话。
- 根因:`setHydrated(true)` 只等账户、模型目录、会话列表;推荐问题、今日星语、校正入口摘要三个 effect 都以 `hydrated` 为门槛,只能在揭幕之后才发请求,于是各自带一套等待表现。这些请求彼此无数据依赖,串行体验是历史堆出来的。
- 修复:启动分两阶段(`bootstrapPhase: "account" → "prepare"`)。账户阶段完成后不揭幕,改进入 prepare:三个 effect 改以 `bootstrapPhase` 为门槛在揭幕前并行发起,并预热校正分包 `import()`;揭幕 effect 在全部适用项就绪(`bootstrapPrepareSettled`)或 4 秒预算(`BOOTSTRAP_PREPARE_TIMEOUT_MS`,从进入 prepare 起算)到期时 `setHydrated(true)`。加载屏文案按阶段切换(`bootstrapLoadingCopy`)。揭幕后删除 `starter-loading` 分支、今日星语 `aria-busy` 与「正在写下」文案(pending 显示静态「今天的星语还没写出来。」,到达后静默替换)、会话切换的 `InlineSpinner`(保留 `sr-only` 文案)。揭幕后按侧栏顺序后台预取最近 5 条会话消息(`sessionIdsToPrefetch`)。账户阶段失败仍直接揭幕到错误屏;8 秒 bootstrap 超时不变。
- 验证:`home-bootstrap-reveal.test.ts`(纯函数 + 源码合同:prepare 门槛 ×3、揭幕延时、预热、预取、揭幕后无 spinner、加载屏 DOM 合同);`daily-starlanguage.test.ts` / `starter-questions.test.ts` 按例外条款改读新门槛;`tsc`、`eslint` 0 error、`next build` `/` Static、首屏 gzip-9 513570 → 513889 B(+0.06%)。
- 防复发:任何首页数据拉取不得以 `hydrated` 为门槛再在揭幕后展示等待态;新增的揭幕前依赖加入 `bootstrapPrepareSettled` 的输入,而不是各自转圈。`main.app-loading[aria-busy="true"]` / `main.app-loading-error` 选择器被 `stale-client-recovery` 依赖,不得改。
- 相关记录:BUG-464(消息按需加载引入的首屏消息等待,流式轮已把当前会话消息前移到揭幕前)、BUG-470(effect 内 setState 规则)
- 复发自:无
- 修复版本:待发布
+2 -2
View File
@@ -401,13 +401,13 @@ or user IDs.
| 类别 | 语义 | 组件 / 样式 | 时长 | `prefers-reduced-motion: reduce` |
| --- | --- | --- | ---: | --- |
| 整页 / 整块阻塞 | 载入账户、正在准备问题、生时评估浮层 | `AppLoadingIndicator`(轨道环 `app-loading-orbit`) | 1.4s linear | 全局 `*` 规则把循环收成一帧;不要改它的 DOM |
| 整页 / 整块阻塞 | 首页揭幕前的两阶段载入(载入账户 → 准备对话)、生时评估浮层 | `AppLoadingIndicator`(轨道环 `app-loading-orbit`) | 1.4s linear | 全局 `*` 规则把循环收成一帧;不要改它的 DOM |
| 行内 / 局部等待 | 出生地解析、两个会话面时间线的 live 步、兜底活动面板的 live 行、个人报告列表与详情 | `InlineSpinner`(`inline-spin`) | 0.8s linear | `animation: none`,收成静止圆点,不要半圈圆弧 |
| 流式生成中 | 引导语打字、时间线 summary 与 live 行的文案 | `onboarding-caret` / `agent-activity-shimmer` | 700ms steps / 1.6s linear | 保持现有全局降级 |
Agent 的 live 标记只有 `InlineSpinner` 一种。曾经并存的 canvas 小球(`thinking-orbs`)已移除,不得再引入第二种 live 标记。
今日星语首次拉取是行内等待,但不用 spinner、也不用透明度呼吸:卡片用静态占位文案(`aria-busy` 仍保留)。轨道环消失后不得再换一套动效继续等。
首页只揭幕一次。揭幕前的加载屏分两阶段:先取账户、模型目录与会话列表,再并行取当前会话消息、推荐问题、今日星语与校正入口摘要,并预热校正分包;全部就绪或 4 秒预算到期(`BOOTSTRAP_PREPARE_TIMEOUT_MS`)才揭幕。揭幕后不得再出现任何阻塞等待或组件级 spinner:推荐问题未到显示安全默认问题,今日星语未到显示静态文案「今天的星语还没写出来。」(不带 `aria-busy`),校正卡用无摘要文案,内容到达后静默替换。切换到消息尚未缓存的会话时消息区留白并只给 `sr-only` 文案,不转圈;揭幕后按侧栏顺序后台预取最近 5 条会话(`SESSION_PREFETCH_COUNT`)让常见切换零等待。轨道环消失后不得再换一套动效继续等。
Admin 的 antd `<Spin>` 是独立设计系统,不在此表。
-9
View File
@@ -1009,7 +1009,6 @@ button:disabled { cursor: default; opacity: .45; }
.product-entrypoint-action { display: inline-flex; flex: 0 0 auto; align-items: center; justify-content: flex-end; gap: var(--space-1); color: var(--color-action); font-size: var(--type-caption); font-weight: 600; white-space: nowrap; }
.product-entrypoint-action .starter-arrow { width: 15px; height: 15px; color: currentColor; }
.rectification-entry-error { grid-column: 1 / -1; margin: 0; }
.starter-loading { color: var(--color-ink-secondary); margin-left: 0; padding: var(--space-5); border-radius: var(--radius-lg); background: var(--color-canvas-muted); font-size: 14px; }
.starter-note { margin: 10px 0 0; color: var(--color-ink-secondary); line-height: 1.5; grid-column: 1 / -1; font-size: 13px; }
.message-list {
--assistant-content-inset: calc(32px + var(--space-3));
@@ -2181,14 +2180,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
transition: color 180ms ease-out, transform 180ms var(--ease-out);
}
.starter-loading {
width: min(1040px, 100%);
margin-left: 0;
padding: var(--space-6);
min-height: min(52vh, 460px);
display: grid;
place-items: center;
}
.starter-note {
margin: calc(var(--space-2) * -1) 0 0;
+63 -21
View File
@@ -5,7 +5,6 @@ import Link from "next/link";
import dynamic from "next/dynamic";
import { useRouter } from "next/navigation";
import { Sparkles } from "lucide-react";
import { InlineSpinner } from "@/components/inline-spinner";
import { useEffect, useRef, useState } from "react";
import type { FormEvent, KeyboardEvent } from "react";
import { AccountDialogOverlay, type AccountOverlayModel } from "@/components/account-dialog-overlay";
@@ -227,6 +226,13 @@ import {
waitForUndoWindow,
writeStoredDailyStarlanguage,
} from "@/lib/home-cloud-sync";
import {
bootstrapLoadingCopy,
bootstrapPrepareSettled,
bootstrapRevealDelayMs,
sessionIdsToPrefetch,
type BootstrapPhase,
} from "@/lib/home-bootstrap";
const BirthTimeRectification = dynamic(
() => import("@/components/birth-time-rectification").then((module) => module.BirthTimeRectification),
@@ -291,6 +297,9 @@ export default function Home() {
const [rectificationTurns, setRectificationTurns] = useState<PersistedRectificationTurn[]>([]);
const [rectificationEntrySummary, setRectificationEntrySummary] = useState<RectificationEntrySummary | null>(null);
const [hydrated, setHydrated] = useState(false);
const [bootstrapPhase, setBootstrapPhase] = useState<BootstrapPhase>("account");
const [rectificationEntrySummarySettled, setRectificationEntrySummarySettled] = useState(false);
const prepareStartedAt = useRef<number | null>(null);
const [guidedJourneyPreview, setGuidedJourneyPreview] = useState(false);
const [profileSaving, setProfileSaving] = useState(false);
const [creatingSession, setCreatingSession] = useState(false);
@@ -518,7 +527,7 @@ export default function Home() {
]);
useEffect(() => {
if (!hydrated || !accountId) return;
if (bootstrapPhase === "account" || !accountId) return;
void (async () => {
try {
const response = await fetch("/api/rectification/cases/entry-summary", { cache: "no-store" });
@@ -527,9 +536,11 @@ export default function Home() {
setRectificationEntrySummary(entrySummaryFromResponse(payload));
} catch {
// The CTA falls back to the server-agnostic default labels.
} finally {
setRectificationEntrySummarySettled(true);
}
})();
}, [accountId, hydrated]);
}, [accountId, bootstrapPhase]);
useEffect(() => {
if (!hydrated || !accountId) return;
@@ -628,12 +639,43 @@ export default function Home() {
: "深入看今日";
const dailyStarlanguageTrend = dailyStarlanguage.kind === "ready"
? dailyStarlanguage.card.trend
: dailyStarlanguage.kind === "pending"
? "正在写下今天的星语。"
: "今天的星语还没写出来。";
: "今天的星语还没写出来。";
const dailyStarlanguageAction = dailyStarlanguage.kind === "ready" ? dailyStarlanguage.card.action : "";
const dailyStarlanguageBusy = natalMinuteAvailable && dailyStarlanguage.kind === "pending";
const onboardingPending = profileComplete && !onboarding && !onboardingError;
const bootstrapPrepareReady = bootstrapPrepareSettled({
profileComplete,
onboardingSettled: onboarding !== null || onboardingError !== "",
dailyStarlanguageApplicable: Boolean(accountId) && profileComplete && natalMinuteAvailable,
dailyStarlanguageSettled: dailyStarlanguage.kind !== "pending",
entrySummarySettled: rectificationEntrySummarySettled,
});
useEffect(() => {
if (hydrated || bootstrapPhase !== "prepare") return;
prepareStartedAt.current ??= Date.now();
const delay = bootstrapRevealDelayMs(Date.now(), prepareStartedAt.current, bootstrapPrepareReady);
const timer = window.setTimeout(() => setHydrated(true), delay);
return () => window.clearTimeout(timer);
}, [bootstrapPhase, bootstrapPrepareReady, hydrated]);
useEffect(() => {
if (bootstrapPhase !== "prepare" || uiPreview.current) return;
void import("@/components/birth-time-rectification");
}, [bootstrapPhase]);
useEffect(() => {
if (!hydrated || uiPreview.current) return;
let cancelled = false;
void (async () => {
for (const sessionId of sessionIdsToPrefetch(sessionsRef.current, activeSessionIdRef.current)) {
if (cancelled) return;
await ensureSessionMessages(sessionId);
}
})();
return () => {
cancelled = true;
};
}, [hydrated]);
const currentOnboardingMessage = onboardingJustCompleted
? startGreeting || completedOnboardingMessage(profileDraft.name.trim())
: onboardingStep === "birth"
@@ -756,6 +798,7 @@ export default function Home() {
async function loadCloudData() {
let redirectedToLogin = false;
let bootstrapFailed = false;
try {
const previewMode = process.env.NODE_ENV === "development"
? new URLSearchParams(window.location.search).get("preview")
@@ -1001,12 +1044,16 @@ export default function Home() {
return;
}
if ((caught as Error).name !== "AbortError" && !controller.signal.aborted) {
bootstrapFailed = true;
setAccountError(friendlyError(caught instanceof Error ? caught.message : "暂时无法读取云端数据"));
}
} finally {
if (redirectedToLogin) return;
window.clearTimeout(bootstrapTimeout);
if (!controller.signal.aborted) setHydrated(true);
if (!controller.signal.aborted) {
if (bootstrapFailed) setHydrated(true);
else setBootstrapPhase("prepare");
}
}
}
@@ -1151,7 +1198,7 @@ export default function Home() {
}, [currentOnboardingMessage, hydrated, shouldStreamOnboarding]);
useEffect(() => {
if (!hydrated || !accountId || !profileComplete || uiPreview.current) return;
if (bootstrapPhase === "account" || !accountId || !profileComplete || uiPreview.current) return;
const requestIdentity = onboardingRequestIdentity(accountId, onboardingFingerprint);
if (isCurrentOnboardingRequest(activeOnboardingRequestIdentity.current, requestIdentity)) return;
activeOnboardingRequestIdentity.current = requestIdentity;
@@ -1185,10 +1232,10 @@ export default function Home() {
}
controller.abort();
};
}, [accountId, hydrated, onboardingFingerprint, profile.name, profileComplete]);
}, [accountId, bootstrapPhase, onboardingFingerprint, profile.name, profileComplete]);
useEffect(() => {
if (!hydrated || !accountId || !profileComplete || !natalMinuteAvailable) return;
if (bootstrapPhase === "account" || !accountId || !profileComplete || !natalMinuteAvailable) return;
const today = calendarDateInTimeZone(new Date(), profile.timezoneId);
const fingerprint = dailyStarlanguageProfileKey(profile);
const stored = readStoredDailyStarlanguage(accountId);
@@ -1229,7 +1276,7 @@ export default function Home() {
controller.abort();
if (retryTimer !== undefined) clearTimeout(retryTimer);
};
}, [accountId, dailyStarlanguageFingerprint, hydrated, natalMinuteAvailable, profileComplete]);
}, [accountId, bootstrapPhase, dailyStarlanguageFingerprint, natalMinuteAvailable, profileComplete]);
useEffect(() => {
if (starterHomeVisible) {
@@ -1491,9 +1538,10 @@ export default function Home() {
}
if (!hydrated || (!account && !accountError)) {
const loadingCopy = bootstrapLoadingCopy(bootstrapPhase);
return (
<main className="app-loading" aria-busy="true" aria-live="polite">
<AppLoadingIndicator title="正在载入账户" detail="同步个人资料与对话记录" />
<AppLoadingIndicator title={loadingCopy.title} detail={loadingCopy.detail} />
</main>
);
}
@@ -1824,11 +1872,7 @@ export default function Home() {
{!profileComplete && onboardingStep === "name" && accountError && <p className="form-error onboarding-inline-error" role="alert">{accountError}</p>}
{profileComplete && presetMessageFinished && !rectificationSurfaceOpen && (onboardingPending ? (
<div className="starter-loading" role="status">
<AppLoadingIndicator title="正在准备问题" detail="根据你的资料整理今天的起点。" />
</div>
) : (
{profileComplete && presetMessageFinished && !rectificationSurfaceOpen && (
<StarterHome
starterGreeting={starterGreeting}
natalMinuteAvailable={natalMinuteAvailable}
@@ -1836,7 +1880,6 @@ export default function Home() {
dailyStarlanguageQuestion={dailyStarlanguageQuestion}
dailyStarlanguageTrend={dailyStarlanguageTrend}
dailyStarlanguageAction={dailyStarlanguageAction}
dailyStarlanguageBusy={dailyStarlanguageBusy}
productEntrypointsDisabled={productEntrypointsDisabled}
startDailyStarlanguageConsultation={startDailyStarlanguageConsultation}
rectificationCardLabel={rectificationCardLabel}
@@ -1853,11 +1896,10 @@ export default function Home() {
startSuggestedConsultation={startSuggestedConsultation}
onboardingError={onboardingError}
/>
))}
)}
</div>
) : sessionMessagesLoading ? (
<div className="message-list session-messages-loading" role="status" aria-busy="true" aria-live="polite">
<InlineSpinner size={20} />
<span className="sr-only">正在加载聊天记录</span>
</div>
) : (
-3
View File
@@ -22,7 +22,6 @@ export type StarterHomeProps = {
readonly dailyStarlanguageQuestion: string;
readonly dailyStarlanguageTrend: string;
readonly dailyStarlanguageAction: string;
readonly dailyStarlanguageBusy: boolean;
readonly productEntrypointsDisabled: boolean;
readonly startDailyStarlanguageConsultation: () => void;
readonly rectificationCardLabel: string;
@@ -47,7 +46,6 @@ export function StarterHome({
dailyStarlanguageQuestion,
dailyStarlanguageTrend,
dailyStarlanguageAction,
dailyStarlanguageBusy,
productEntrypointsDisabled,
startDailyStarlanguageConsultation,
rectificationCardLabel,
@@ -85,7 +83,6 @@ export function StarterHome({
<div className="product-entrypoint-copy">
<h2 id="daily-starlanguage-title">{natalMinuteAvailable ? "今日星语" : "每日运势"}</h2>
<p
aria-busy={dailyStarlanguageBusy}
role={natalMinuteAvailable && dailyStarlanguage.kind !== "ready" ? "status" : undefined}
>{natalMinuteAvailable ? dailyStarlanguageTrend : "看看今天的整体节奏、适合推进的事和需要留意的地方。"}</p>
</div>
+63
View File
@@ -0,0 +1,63 @@
/**
* Home bootstrap reveal policy.
*
* The loading screen owns every wait before the page appears. Phase "account"
* fetches account, model catalog and the session list; phase "prepare" fetches
* the starter questions, today's starlanguage and the rectification entry
* summary in parallel. The page is revealed once every applicable item has
* settled, or when the prepare budget runs out — never with a spinner inside.
*/
export type BootstrapPhase = "account" | "prepare";
export const BOOTSTRAP_PREPARE_TIMEOUT_MS = 4000;
export const SESSION_PREFETCH_COUNT = 5;
export type BootstrapPrepareState = Readonly<{
profileComplete: boolean;
onboardingSettled: boolean;
dailyStarlanguageApplicable: boolean;
dailyStarlanguageSettled: boolean;
entrySummarySettled: boolean;
}>;
export function bootstrapPrepareSettled(state: BootstrapPrepareState): boolean {
if (!state.entrySummarySettled) return false;
if (state.profileComplete && !state.onboardingSettled) return false;
if (state.dailyStarlanguageApplicable && !state.dailyStarlanguageSettled) return false;
return true;
}
export function bootstrapRevealDelayMs(now: number, prepareStartedAt: number | null, settled: boolean): number {
if (settled) return 0;
if (prepareStartedAt === null) return BOOTSTRAP_PREPARE_TIMEOUT_MS;
return Math.max(0, BOOTSTRAP_PREPARE_TIMEOUT_MS - (now - prepareStartedAt));
}
export function bootstrapLoadingCopy(phase: BootstrapPhase): Readonly<{ title: string; detail: string }> {
return phase === "prepare"
? { title: "正在准备对话", detail: "整理推荐问题、今日星语与对话记录" }
: { title: "正在载入账户", detail: "同步个人资料与对话记录" };
}
export type PrefetchableSession = Readonly<{
id: string;
sessionType: string;
messagesHydrated?: boolean;
}>;
export function sessionIdsToPrefetch(
sessions: readonly PrefetchableSession[],
activeSessionId: string,
limit = SESSION_PREFETCH_COUNT,
): string[] {
const ids: string[] = [];
for (const session of sessions) {
if (ids.length >= limit) break;
if (session.id === activeSessionId) continue;
if (session.messagesHydrated) continue;
if (session.sessionType !== "consultation") continue;
ids.push(session.id);
}
return ids;
}
+7 -3
View File
@@ -309,11 +309,15 @@ test("the homepage card is engine-backed first, with Agent polish off the reques
test("the home requests the card exactly when it renders one, and retries a failed day once", () => {
const page = homeSurface;
const effect = page.slice(
page.indexOf("if (!hydrated || !accountId || !profileComplete || !natalMinuteAvailable) return;"),
page.indexOf("}, [accountId, dailyStarlanguageFingerprint, hydrated, natalMinuteAvailable, profileComplete]);"),
// Former value: guarded on `!hydrated`; the card is now requested during the bootstrap
// "prepare" phase so it is settled before the page is revealed.
page.indexOf("if (bootstrapPhase === \"account\" || !accountId || !profileComplete || !natalMinuteAvailable) return;"),
page.indexOf("}, [accountId, bootstrapPhase, dailyStarlanguageFingerprint, natalMinuteAvailable, profileComplete]);"),
);
assert.match(page, /aria-busy=\{dailyStarlanguageBusy\}/);
// Former value: assert.match(page, /aria-busy=\{dailyStarlanguageBusy\}/) — the first fetch
// no longer shows a busy state after reveal; a pending card reads as the static copy.
assert.doesNotMatch(page, /dailyStarlanguageBusy/);
assert.ok(effect.length > 0);
assert.doesNotMatch(page, /birthTimeDisplayState\(profile\)/);
assert.match(effect, /next\.kind === "unavailable" && remainingRetries > 0/);
@@ -0,0 +1,104 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
BOOTSTRAP_PREPARE_TIMEOUT_MS,
SESSION_PREFETCH_COUNT,
bootstrapLoadingCopy,
bootstrapPrepareSettled,
bootstrapRevealDelayMs,
sessionIdsToPrefetch,
} from "../src/lib/home-bootstrap.ts";
import { homeSurface } from "./home-surface.ts";
// Product rule (2026-09-02): the loading screen owns every wait. After the home
// page is revealed there is no component-level spinner left — only the streaming
// answer keeps a live marker, because that is content being generated, not data
// being loaded.
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const starterHome = readFileSync(new URL("../src/components/starter-home.tsx", import.meta.url), "utf8");
const globalsCss = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const staleClientRecovery = readFileSync(new URL("../src/components/stale-client-recovery.tsx", import.meta.url), "utf8");
test("prepare phase settles only when every applicable item has an answer", () => {
const base = {
profileComplete: true,
onboardingSettled: true,
dailyStarlanguageApplicable: true,
dailyStarlanguageSettled: true,
entrySummarySettled: true,
};
assert.equal(bootstrapPrepareSettled(base), true);
assert.equal(bootstrapPrepareSettled({ ...base, entrySummarySettled: false }), false);
assert.equal(bootstrapPrepareSettled({ ...base, onboardingSettled: false }), false);
assert.equal(bootstrapPrepareSettled({ ...base, dailyStarlanguageSettled: false }), false);
// Items that do not apply to this account never hold the reveal.
assert.equal(bootstrapPrepareSettled({ ...base, profileComplete: false, onboardingSettled: false }), true);
assert.equal(bootstrapPrepareSettled({ ...base, dailyStarlanguageApplicable: false, dailyStarlanguageSettled: false }), true);
});
test("reveal delay is zero once settled and counts down from the phase start otherwise", () => {
assert.equal(BOOTSTRAP_PREPARE_TIMEOUT_MS, 4000);
assert.equal(bootstrapRevealDelayMs(10_000, 9_000, true), 0);
assert.equal(bootstrapRevealDelayMs(10_000, 9_000, false), 3000);
assert.equal(bootstrapRevealDelayMs(20_000, 9_000, false), 0);
assert.equal(bootstrapRevealDelayMs(10_000, null, false), BOOTSTRAP_PREPARE_TIMEOUT_MS);
});
test("loading copy changes per phase so the single loading screen narrates progress", () => {
const account = bootstrapLoadingCopy("account");
const prepare = bootstrapLoadingCopy("prepare");
assert.notEqual(account.title, prepare.title);
assert.notEqual(account.detail, prepare.detail);
assert.match(page, /const loadingCopy = bootstrapLoadingCopy\(bootstrapPhase\);/);
assert.match(page, /<AppLoadingIndicator title=\{loadingCopy\.title\} detail=\{loadingCopy\.detail\} \/>/);
});
test("bootstrap enters the prepare phase instead of revealing after the account fetch", () => {
assert.match(page, /if \(bootstrapFailed\) setHydrated\(true\);\n\s*else setBootstrapPhase\("prepare"\);/);
// The three secondary fetches start in the prepare phase, not after reveal.
const guards = page.match(/if \(bootstrapPhase === "account" \|\| !accountId/g) ?? [];
assert.equal(guards.length, 3, "onboarding, daily starlanguage and entry summary must all key off bootstrapPhase");
assert.match(page, /setRectificationEntrySummarySettled\(true\);/);
assert.match(page, /bootstrapRevealDelayMs\(Date\.now\(\), prepareStartedAt\.current, bootstrapPrepareReady\)/);
assert.match(page, /window\.setTimeout\(\(\) => setHydrated\(true\), delay\)/);
assert.match(page, /void import\("@\/components\/birth-time-rectification"\);/);
});
test("revealed home carries no component loading state", () => {
assert.doesNotMatch(page, /starter-loading/);
assert.doesNotMatch(starterHome, /starter-loading|dailyStarlanguageBusy|aria-busy=\{/);
assert.doesNotMatch(globalsCss, /\.starter-loading/);
assert.doesNotMatch(page, /InlineSpinner/);
assert.doesNotMatch(page, /dailyStarlanguageBusy|正在写下今天的星语/);
// Switching to a session whose messages are not cached keeps a silent placeholder.
assert.match(page, /className="message-list session-messages-loading" role="status" aria-busy="true" aria-live="polite">\n\s*<span className="sr-only">正在加载聊天记录<\/span>/);
});
test("recent sessions are prefetched after reveal so switches do not wait", () => {
assert.equal(SESSION_PREFETCH_COUNT, 5);
const sessions = [
{ id: "active", sessionType: "consultation" },
{ id: "a", sessionType: "consultation" },
{ id: "b", sessionType: "consultation", messagesHydrated: true },
{ id: "r", sessionType: "birth_time_rectification" },
{ id: "c", sessionType: "consultation" },
{ id: "d", sessionType: "consultation" },
{ id: "e", sessionType: "consultation" },
{ id: "f", sessionType: "consultation" },
{ id: "g", sessionType: "consultation" },
];
assert.deepEqual(sessionIdsToPrefetch(sessions, "active"), ["a", "c", "d", "e", "f"]);
assert.match(page, /sessionIdsToPrefetch\(sessionsRef\.current, activeSessionIdRef\.current\)/);
assert.match(page, /await ensureSessionMessages\(sessionId\);/);
});
test("the loading screen DOM contract stale-client recovery depends on is untouched", () => {
assert.match(page, /<main className="app-loading" aria-busy="true" aria-live="polite">/);
assert.match(page, /<main className="app-loading app-loading-error">/);
assert.match(staleClientRecovery, /main\.app-loading\[aria-busy="true"\]/);
assert.match(staleClientRecovery, /main\.app-loading-error/);
assert.ok(homeSurface.includes("bootstrapPrepareSettled("));
});
+4 -1
View File
@@ -26,7 +26,10 @@ function sourceBetween(source: string, startMarker: string, endMarker: string) {
test("keeps starter questions visible while the user edits a draft", () => {
// Given: the empty-session starter block and its render guard.
const guardStart = pageSource.indexOf("{profileComplete && presetMessageFinished");
const onboardingBranch = pageSource.indexOf("(onboardingPending ?", guardStart);
// Former value: pageSource.indexOf("(onboardingPending ?", guardStart) — the guard used to
// branch into a `starter-loading` block. Home now reveals only after the starter questions
// settle (or time out), so the guard leads straight into <StarterHome>.
const onboardingBranch = pageSource.indexOf("<StarterHome", guardStart);
// When: the guard is inspected independently of the card copy and layout.
assert.notEqual(guardStart, -1);