fix: reject stale onboarding recovery
This commit is contained in:
+23
-12
@@ -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<string, number>());
|
||||
const activeSessionIdRef = useRef("");
|
||||
const chartLibraryLoadedAccount = useRef("");
|
||||
const onboardingRequestIdentity = useRef("");
|
||||
const onboardingPresentation = useRef({ name: "", startGreeting: "" });
|
||||
const activeOnboardingRequestIdentity = useRef("");
|
||||
const uiPreview = useRef(false);
|
||||
const uiPreviewMode = useRef<string | null>(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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<void>((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<void>((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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user