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
-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;
}