Files
Jyotisha/frontend/src/lib/home-warm-prefetch.ts
T

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