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:
co-authored by
Claude Fable 5.1
parent
7557e0d2ed
commit
482796fc52
@@ -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 时会一并带入该分支(其独立验收见该轮记录)。
|
||||
@@ -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
@@ -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>` 是独立设计系统,不在此表。
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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("));
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user