diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 2e19fc61..97ac95ad 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -46,6 +46,9 @@ import { chatMessageViews, type ChatMessage } from "@/lib/chat-message-view"; import { OnboardingAuthenticationError, type OnboardingContent, + isCurrentOnboardingRequest, + onboardingProfileFingerprint, + onboardingRequestIdentity, requestOnboardingWithRecovery, } from "@/lib/onboarding-client"; import { protectOnboardingPhrases } from "@/lib/onboarding-copy"; @@ -760,8 +763,7 @@ export default function Home() { const modelSelectionVersions = useRef(new Map()); const activeSessionIdRef = useRef(""); const chartLibraryLoadedAccount = useRef(""); - const onboardingRequestIdentity = useRef(""); - const onboardingPresentation = useRef({ name: "", startGreeting: "" }); + const activeOnboardingRequestIdentity = useRef(""); const uiPreview = useRef(false); const uiPreviewMode = useRef(null); const birthTimeRevisionPending = useRef(false); @@ -783,7 +785,7 @@ export default function Home() { const productEntrypointsDisabled = !hydrated || Boolean(pendingSessionId) || cancellationPending || !account || !modelCatalog; const activeStreamingText = streamingReply && streamingReply.sessionId === activeSession?.id ? streamingReply.text : ""; const accountId = account?.user.id; - onboardingPresentation.current = { name: profile.name, startGreeting }; + const onboardingFingerprint = onboardingProfileFingerprint(profile); useEffect(() => { activeSessionIdRef.current = activeSessionId; @@ -1114,16 +1116,22 @@ export default function Home() { useEffect(() => { if (!hydrated || !accountId || !profileComplete || uiPreview.current) return; - const requestIdentity = `${accountId}:profile-complete`; - if (onboardingRequestIdentity.current === requestIdentity) return; - onboardingRequestIdentity.current = requestIdentity; + const requestIdentity = onboardingRequestIdentity(accountId, onboardingFingerprint); + if (isCurrentOnboardingRequest(activeOnboardingRequestIdentity.current, requestIdentity)) return; + activeOnboardingRequestIdentity.current = requestIdentity; + const presentation = { name: profile.name, startGreeting }; const controller = new AbortController(); + setOnboarding(null); + setOnboardingError(""); void requestOnboardingWithRecovery(controller.signal, () => { - if (!controller.signal.aborted) setOnboardingError("个性化入门问题准备超时"); + if (!controller.signal.aborted + && isCurrentOnboardingRequest(activeOnboardingRequestIdentity.current, requestIdentity)) { + setOnboardingError("个性化入门问题准备超时"); + } }) .then((content) => { - if (controller.signal.aborted) return; - const presentation = onboardingPresentation.current; + if (controller.signal.aborted + || !isCurrentOnboardingRequest(activeOnboardingRequestIdentity.current, requestIdentity)) return; setOnboarding({ ...content, greeting: presentation.startGreeting || createStartGreeting(presentation.name), @@ -1131,7 +1139,8 @@ export default function Home() { setOnboardingError(""); }) .catch((caught: unknown) => { - if (controller.signal.aborted) return; + if (controller.signal.aborted + || !isCurrentOnboardingRequest(activeOnboardingRequestIdentity.current, requestIdentity)) return; if (caught instanceof OnboardingAuthenticationError) { window.location.assign("/login"); return; @@ -1139,10 +1148,12 @@ export default function Home() { setOnboardingError(caught instanceof Error ? caught.message : "暂时无法准备初始问题"); }); return () => { - if (onboardingRequestIdentity.current === requestIdentity) onboardingRequestIdentity.current = ""; + if (isCurrentOnboardingRequest(activeOnboardingRequestIdentity.current, requestIdentity)) { + activeOnboardingRequestIdentity.current = ""; + } controller.abort(); }; - }, [accountId, hydrated, profileComplete]); + }, [accountId, hydrated, onboardingFingerprint, profile.name, profileComplete, startGreeting]); useEffect(() => { if (!hydrated || !profileComplete || birthTimeDisplayState(profile)) return; diff --git a/frontend/src/lib/onboarding-client.ts b/frontend/src/lib/onboarding-client.ts index 52ed9d8d..415afec2 100644 --- a/frontend/src/lib/onboarding-client.ts +++ b/frontend/src/lib/onboarding-client.ts @@ -28,6 +28,24 @@ type OnboardingAttemptResult = | { readonly kind: "network"; readonly error: unknown } | { readonly kind: "invalid-response"; readonly error: unknown }; +type OnboardingProfileFingerprintInput = { + readonly name: string; + readonly date: string; + readonly time: string; + readonly reportedTime: string; + readonly birthTimeSource: string; + readonly birthTimePeriod: string; + readonly birthTimeClue: string; + readonly uncertaintyBeforeMinutes: number | null; + readonly uncertaintyAfterMinutes: number | null; + readonly birthTimeStatus: string; + readonly rectificationCaseId: string; + readonly countryCode: string; + readonly provinceCode: string; + readonly cityCode: string; + readonly districtCode: string; +}; + export type OnboardingSuggestion = { readonly theme: "career" | "marriage" | "timing"; readonly text: string; @@ -38,6 +56,34 @@ export type OnboardingContent = { readonly suggestions: readonly OnboardingSuggestion[]; }; +export function onboardingProfileFingerprint(profile: OnboardingProfileFingerprintInput): string { + return JSON.stringify([ + profile.name, + profile.date, + profile.time, + profile.reportedTime, + profile.birthTimeSource, + profile.birthTimePeriod, + profile.birthTimeClue, + profile.uncertaintyBeforeMinutes, + profile.uncertaintyAfterMinutes, + profile.birthTimeStatus, + profile.rectificationCaseId, + profile.countryCode, + profile.provinceCode, + profile.cityCode, + profile.districtCode, + ]); +} + +export function onboardingRequestIdentity(accountId: string, profileFingerprint: string): string { + return JSON.stringify([accountId, profileFingerprint]); +} + +export function isCurrentOnboardingRequest(currentIdentity: string, completedIdentity: string): boolean { + return currentIdentity.length > 0 && currentIdentity === completedIdentity; +} + export class OnboardingAuthenticationError extends Error { readonly name = "OnboardingAuthenticationError"; readonly status = 401; diff --git a/frontend/tests/onboarding-client.test.ts b/frontend/tests/onboarding-client.test.ts index 8d3ba503..7d215f65 100644 --- a/frontend/tests/onboarding-client.test.ts +++ b/frontend/tests/onboarding-client.test.ts @@ -3,6 +3,9 @@ import test from "node:test"; import { OnboardingAuthenticationError, OnboardingRequestError, + isCurrentOnboardingRequest, + onboardingProfileFingerprint, + onboardingRequestIdentity, requestOnboardingWithRecovery, } from "../src/lib/onboarding-client.ts"; @@ -16,6 +19,26 @@ const personalizedOnboarding = { source: "cache", } as const; +const completeProfile = { + name: "林遥", date: "1990-06-15", time: "12:30", reportedTime: "12:30", + birthTimeSource: "hospital_record", birthTimePeriod: "", birthTimeClue: "出生证明", birthTimeStatus: "confirmed", + uncertaintyBeforeMinutes: 0, uncertaintyAfterMinutes: 0, rectificationCaseId: "", countryCode: "CN", provinceCode: "110000", cityCode: "110000-city", districtCode: "110101", +} as const; + +test("rejects a stale completion after a complete profile changes", () => { + // Given: one account starts onboarding for a complete persisted profile. + const firstIdentity = onboardingRequestIdentity("account-1", onboardingProfileFingerprint(completeProfile)); + + // When: a presentation-affecting profile field changes before that request completes. + const changedFingerprint = onboardingProfileFingerprint({ ...completeProfile, name: "周宁" }); + const currentIdentity = onboardingRequestIdentity("account-1", changedFingerprint); + + // Then: the new profile has a distinct request identity and the old completion is stale. + assert.notEqual(currentIdentity, firstIdentity); + assert.equal(isCurrentOnboardingRequest(currentIdentity, firstIdentity), false); + assert.equal(isCurrentOnboardingRequest(currentIdentity, currentIdentity), true); +}); + test("returns personalized cache content after a timeout and pending response", async () => { // Given: the first request times out, the second is provisional, and the third is terminal. const originalFetch = globalThis.fetch; @@ -256,3 +279,32 @@ test("parent cancellation interrupts response body parsing", async () => { globalThis.fetch = originalFetch; } }); + +test("parent cancellation interrupts a pending-response retry delay", async () => { + // Given: the first response is pending and the next retry has a long delay. + const originalFetch = globalThis.fetch; + const parent = new AbortController(); + let requestCount = 0; + let markFetched: (() => void) | null = null; + const fetched = new Promise((resolve) => { markFetched = resolve; }); + globalThis.fetch = () => { + requestCount += 1; markFetched?.(); + return Promise.resolve(Response.json({ ...personalizedOnboarding, source: "pending" })); + }; + + try { + // When: the page unmounts after the provisional response enters its retry wait. + const request = requestOnboardingWithRecovery(parent.signal, () => undefined, { + requestTimeoutMs: 10_000, retryDelayMs: 10_000, maxAttempts: 3, + }); + await fetched; + await new Promise((resolve) => setImmediate(resolve)); + parent.abort(new DOMException("page unmounted", "AbortError")); + + // Then: the delay is cancelled and no second request starts. + await assert.rejects(request, (error: unknown) => error instanceof DOMException && error.name === "AbortError"); + assert.equal(requestCount, 1); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/frontend/tests/starter-questions.test.ts b/frontend/tests/starter-questions.test.ts index f6439761..d317cd34 100644 --- a/frontend/tests/starter-questions.test.ts +++ b/frontend/tests/starter-questions.test.ts @@ -28,25 +28,6 @@ test("keeps starter questions visible while the user edits a draft", () => { assert.doesNotMatch(starterVisibilityGuard, /\bdraft\b/); }); -test("keeps onboarding recovery alive after safe defaults become visible", () => { - // Given: the homepage owns one recovery sequence per completed account profile. - const recoveryEffect = sourceBetween( - pageSource, - "const onboardingRequestIdentity =", - " useEffect(() => {\n if (!hydrated || !profileComplete || birthTimeDisplayState(profile)) return;", - ); - - // When: the request identity, client call, and effect dependencies are inspected. - // Then: fallback state cannot cancel recovery and only authentication triggers login. - assert.match(pageSource, /requestOnboardingWithRecovery/); - assert.match(pageSource, /OnboardingAuthenticationError/); - assert.match(recoveryEffect, /onboardingRequestIdentity\.current === requestIdentity/); - assert.match(recoveryEffect, /requestOnboardingWithRecovery\(controller\.signal,/); - assert.match(recoveryEffect, /caught instanceof OnboardingAuthenticationError/); - assert.match(recoveryEffect, /\}, \[accountId, hydrated, profileComplete\]\);/); - assert.doesNotMatch(pageSource, /function readOnboarding\(/); -}); - test("keeps follow-up suggestions visible while the user edits a draft", () => { // Given: the follow-up suggestion block and its render guard. const suggestionGuard = sourceBetween(