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
@@ -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>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user