Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
115 lines
5.0 KiB
TypeScript
115 lines
5.0 KiB
TypeScript
/**
|
|
* Warm the Home snapshot from a secondary page (BUG-1104).
|
|
*
|
|
* BUG-1040's warm return only works after Home has cold-started once in this
|
|
* document. A tab that opens on /chart, /ephemeris, /reports or /people (a
|
|
* refresh, a shared link, the post-login landing of a deep link) has no
|
|
* snapshot, so its first 「新建对话」 plays the whole loading ring.
|
|
*
|
|
* While such a page sits idle, fetch exactly what `isCompleteHomeWarmSnapshot`
|
|
* requires — the model catalog and the rectification entry summary — with the
|
|
* same client helpers the cold start uses, and write it through the same
|
|
* `writeHomeWarmSnapshot`. Read-only GETs only: no fallback PATCH, no
|
|
* consultation recovery. Those still run in Home's warm refresh after landing.
|
|
* The cursor is left to the provider's own boot (`listBoot: null`).
|
|
*
|
|
* Pure module state, no React.
|
|
*/
|
|
|
|
import { fetchDailyStarlanguage, fetchModelCatalog, fetchRectificationEntrySummary, writeStoredDailyStarlanguage } from "./home-cloud-sync";
|
|
import { dailyStarlanguageWarmKey } from "./home-warm-start";
|
|
import { isCompleteHomeWarmSnapshot, readHomeWarmDaily, readHomeWarmSnapshot, rememberHomeWarmDaily, writeHomeWarmSnapshot } from "./home-warm-snapshot";
|
|
import type { DailyStarlanguageState, Profile } from "./home-types";
|
|
import type { PublicLanguageModelCatalog } from "./public-models";
|
|
import type { RectificationEntrySummary } from "./rectification-entry";
|
|
|
|
export type HomeWarmPrefetchIo = Readonly<{
|
|
fetchModelCatalog: (signal?: AbortSignal) => Promise<PublicLanguageModelCatalog>;
|
|
fetchRectificationEntrySummary: (signal?: AbortSignal) => Promise<RectificationEntrySummary | null>;
|
|
}>;
|
|
|
|
const defaultIo: HomeWarmPrefetchIo = { fetchModelCatalog, fetchRectificationEntrySummary };
|
|
|
|
let inflight: { accountId: string; promise: Promise<boolean> } | null = null;
|
|
|
|
/**
|
|
* Fill the snapshot for `accountId` unless it is already complete. Resolves to
|
|
* true when this call wrote a complete snapshot. Any failure leaves nothing
|
|
* behind: the next Home simply takes the unchanged cold path.
|
|
* `isCurrent` is asked again before writing, so an account switch, sign-out
|
|
* or a Home that mounted meanwhile never receives a late write.
|
|
*/
|
|
export function prefetchHomeWarmSnapshot(
|
|
accountId: string,
|
|
isCurrent: () => boolean,
|
|
io: HomeWarmPrefetchIo = defaultIo,
|
|
signal?: AbortSignal,
|
|
): Promise<boolean> {
|
|
if (!accountId || isCompleteHomeWarmSnapshot(readHomeWarmSnapshot(accountId))) return Promise.resolve(false);
|
|
if (inflight && inflight.accountId === accountId) return inflight.promise;
|
|
const promise = (async () => {
|
|
try {
|
|
const [modelCatalog, summary] = await Promise.all([
|
|
io.fetchModelCatalog(signal),
|
|
// Same settle rule as the cold start: a failed summary reads as the default card.
|
|
io.fetchRectificationEntrySummary(signal).catch(() => null),
|
|
]);
|
|
if (signal?.aborted || !isCurrent()) return false;
|
|
if (isCompleteHomeWarmSnapshot(readHomeWarmSnapshot(accountId))) return false;
|
|
const next = { accountId, modelCatalog, entrySummary: summary, entrySummarySettled: true, sessionsCursor: null, listBoot: null };
|
|
if (!isCompleteHomeWarmSnapshot(next)) return false;
|
|
writeHomeWarmSnapshot(next);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
} finally {
|
|
inflight = null;
|
|
}
|
|
})();
|
|
inflight = { accountId, promise };
|
|
return promise;
|
|
}
|
|
|
|
export function resetHomeWarmPrefetchForTests(): void {
|
|
inflight = null;
|
|
}
|
|
|
|
export type HomeWarmDailyIo = Readonly<{
|
|
fetchDailyStarlanguage: (signal: AbortSignal | undefined, subjectId: string) => Promise<DailyStarlanguageState>;
|
|
now: () => Date;
|
|
}>;
|
|
|
|
const defaultDailyIo: HomeWarmDailyIo = {
|
|
fetchDailyStarlanguage: (signal, subjectId) => fetchDailyStarlanguage(signal ?? new AbortController().signal, subjectId),
|
|
now: () => new Date(),
|
|
};
|
|
|
|
/**
|
|
* Today's home line for the current person (BUG-1110). Without it, Home's first
|
|
* frame after 「新建对话」 from a secondary page showed the placeholder 「今天的星语
|
|
* 还没写出来。」 and swapped in the line a moment later. Same fetch and the same
|
|
* key the binder uses; stored exactly where the binder stores it. Read-only;
|
|
* nothing is written when the person has no card or the request fails.
|
|
*/
|
|
export async function prefetchHomeWarmDaily(
|
|
accountId: string,
|
|
subjectId: string,
|
|
profile: Profile | null,
|
|
isCurrent: () => boolean,
|
|
io: HomeWarmDailyIo = defaultDailyIo,
|
|
signal?: AbortSignal,
|
|
): Promise<boolean> {
|
|
const key = dailyStarlanguageWarmKey(subjectId, profile, io.now());
|
|
if (!accountId || !key) return false;
|
|
if (readHomeWarmDaily({ accountId, subjectId, ...key })) return false;
|
|
try {
|
|
const next = await io.fetchDailyStarlanguage(signal, subjectId);
|
|
if (signal?.aborted || !isCurrent() || next.kind !== "ready") return false;
|
|
rememberHomeWarmDaily({ accountId, subjectId, ...key, card: next.card });
|
|
writeStoredDailyStarlanguage(accountId, { ...key, card: next.card });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|