Instant home on reopen: snapshot of profile/grades/map/stats on the device, refreshed behind the scenes

A signed-in user with a snapshot goes straight to home; the same boot requests run in the
background and replace it. Snapshot is cleared whenever tokens are cleared and on a new login,
ignored from another build or when older than 7 days, and dropped when the fresh profile is a
different user (reboot remounts MainApp via a generation key; AnimatePresence otherwise kept the
exiting one). Notifications/characters wait for the profile answer so an expired access token
costs the same requests as before. refreshHome applies only the latest answer. Home shows a
"saved data" strip when the map could not be refreshed.

Measured locally (old vs new build, same backend, Chrome throttling, median of 3, time to map):
Fast 3G 2.46s -> 1.80s (expired token 3.63s -> 1.80s); Slow 3G 8.52s -> 6.14s (expired 12.6s ->
6.13s); API requests per open unchanged (6 valid, 11 expired). 12/12 behaviour checks passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 19:57:42 +03:30
parent f0b7a5e28f
commit f7009b11e8
10 changed files with 238 additions and 24 deletions

View File

@@ -2,6 +2,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
import { api, type Character, type Grade, type HomeResponse, type UserProfile, type UserSummary } from '@/api';
import { seyarFromSlug, type SeyarChar } from '@/mascots/seyar';
import { companionStore, useCompanion } from '@/state/companion';
import { homeSnapshot } from '@/state/homeSnapshot';
import { fetchHome, subjectStore } from '@/state/subject';
/**
@@ -42,16 +43,26 @@ export interface Prefetch {
export function AppDataProvider({
initialProfile,
grades,
grades: initialGrades,
prefetch,
stale = false,
onIdentityChanged,
children,
}: {
initialProfile: UserProfile;
grades: Grade[];
prefetch?: Prefetch;
/**
* داده‌ی اولیه عکسِ ذخیره‌شده روی گوشی است (state/homeSnapshot.ts)، نه پاسخِ همین حالا: همه‌چیز پشتِ صحنه
* دوباره گرفته می‌شود. همان چهار درخواستِ بوت، پس بارِ سرور بیشتر نمی‌شود؛ فقط کاربر منتظرشان نمی‌ماند.
*/
stale?: boolean;
/** پروفایلِ تازه با عکس نمی‌خواند (کاربرِ دیگر، یا بی‌نام/بی‌پایه که باید به تکمیلِ پروفایل برود). */
onIdentityChanged?: () => void;
children: ReactNode;
}) {
const [profile, setProfile] = useState(initialProfile);
const [grades, setGrades] = useState(initialGrades);
const [summary, setSummary] = useState<UserSummary | null>(prefetch?.summary ?? null);
const [home, setHome] = useState<HomeResponse | null>(prefetch?.home ?? null);
const [homeError, setHomeError] = useState<unknown>(null);
@@ -63,16 +74,21 @@ export function AppDataProvider({
const gradeId = profile.gradeId;
// نقشه‌ی کتاب‌هایی که دیده شده؛ جابه‌جایی به کتابِ دیده‌شده فوری است و پشت‌صحنه تازه می‌شود.
const homeCache = useRef(new Map<string, HomeResponse>());
// فقط پاسخِ آخرین درخواست روی صفحه می‌نشیند: وقتی پایه یا کتاب وسطِ راه عوض شود (مثلاً پروفایلِ تازه‌ی
// پشتِ صحنه پایه‌ی دیگری گفت)، پاسخِ کندترِ درخواستِ قبلی نباید نقشه‌ی تازه را بپوشاند.
const homeSeq = useRef(0);
const refreshHome = useCallback(async (forGrade?: number) => {
const target = forGrade ?? gradeId;
const seq = ++homeSeq.current;
try {
const fresh = await fetchHome(target);
if (fresh.subject) homeCache.current.set(`${target}:${fresh.subject.key}`, fresh);
if (seq !== homeSeq.current) return;
setHome(fresh);
setHomeError(null);
} catch (err) {
setHomeError(err);
if (seq === homeSeq.current) setHomeError(err);
}
}, [gradeId]);
@@ -106,22 +122,76 @@ export function AppDataProvider({
}
}, []);
// بارِ اول فقط یک بار؛ عوض شدنِ پایه refreshHome را تازه می‌سازد و نباید همه‌ی این‌ها دوباره گرفته شوند.
const started = useRef(false);
// عکس مالِ این کاربر نبود: این AppData تا برداشته شدن دیگر عکس نمی‌نویسد (پاسخِ دیرِ نقشه با پروفایلِ غلط).
const identityLost = useRef(false);
useEffect(() => {
// آنچه در اسپلش رسیده دوباره گرفته نمی‌شود؛ فقط کم‌ها.
if (!prefetch?.home) void refreshHome();
if (!prefetch?.summary) void refreshSummary();
void refreshUnread();
api.characters
.list()
.then((list) => {
// بی‌انتخاب یعنی پیش‌فرضِ سرور (سیگما)؛ همراهِ کاربرِ قبلی روی این دستگاه نباید بماند.
setCompanionChar(seyarFromSlug(list.find((c) => c.selected)?.slug) ?? 'sigma');
})
.catch((err: unknown) => console.warn('companion lookup failed; keeping the stored one', err));
// فقط بارِ اول؛ prefetch بعد از mount عوض نمی‌شود.
if (started.current) return;
started.current = true;
// اعلان‌ها و همراه برای صفحه‌ی اول لازم نیستند.
const loadExtras = () => {
if (!api.auth.isSignedIn()) return;
void refreshUnread();
api.characters
.list()
.then((list) => {
// بی‌انتخاب یعنی پیش‌فرضِ سرور (سیگما)؛ همراهِ کاربرِ قبلی روی این دستگاه نباید بماند.
setCompanionChar(seyarFromSlug(list.find((c) => c.selected)?.slug) ?? 'sigma');
})
.catch((err: unknown) => console.warn('companion lookup failed; keeping the stored one', err));
};
if (stale) {
api.profile
.get()
.then((fresh) => {
if (fresh.userId !== initialProfile.userId || !fresh.name || fresh.gradeId == null) {
identityLost.current = true;
onIdentityChanged?.();
return false;
}
setProfile(fresh);
// پایه در دستگاهِ دیگری عوض شده؛ نقشه‌ای که هم‌زمان با پایه‌ی عکس گرفته شد به کار نمی‌آید.
if (fresh.gradeId !== initialProfile.gradeId) void refreshHome(fresh.gradeId);
return true;
})
// ۴۰۱ را http خودش به خروج می‌برد. بقیه (اینترنتِ قطع، ۵xx): پروفایلِ عکس می‌ماند و نوارِ «اطلاعاتِ
// ذخیره‌شده» در خانه (homeError) خبر می‌دهد؛ نقشه هم‌زمان همین درخواست‌ها را می‌زند و همان‌جا شکست می‌خورد.
.catch((err: unknown) => {
console.warn('profile refresh failed; showing the stored one', err);
return true;
})
// بعد از پروفایل، نه هم‌زمان: با توکنِ یک‌ساعته‌ی منقضی هر درخواستِ هم‌زمان یک ۴۰۱ و یک تکرار می‌شود؛
// این‌طور تعدادِ درخواست‌ها همان بوتِ قبلی است (۴ + refresh + ۴ تکرار + ۲).
.then((stays) => stays && loadExtras());
api
.grades()
.then(setGrades)
.catch((err: unknown) => console.warn('grades refresh failed; keeping the stored list', err));
void refreshHome();
void refreshSummary();
} else {
// آنچه در اسپلش رسیده دوباره گرفته نمی‌شود؛ فقط کم‌ها.
if (!prefetch?.home) void refreshHome();
if (!prefetch?.summary) void refreshSummary();
loadExtras();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [refreshHome, refreshSummary, refreshUnread]);
// هر داده‌ی تازه عکسِ خانه را به‌روز می‌کند. بارِ اولِ حالتِ stale همان عکسِ خوانده‌شده است و نوشته نمی‌شود،
// تا savedAt فقط با پاسخِ سرور جلو برود.
const skipSnapshotWrite = useRef(stale);
useEffect(() => {
if (skipSnapshotWrite.current) {
skipSnapshotWrite.current = false;
return;
}
// پاسخی که بعد از خروج رسید (توکن پاک شده) نباید عکسِ تازه بسازد.
if (!home || !summary || identityLost.current || !api.auth.isSignedIn()) return;
homeSnapshot.save({ profile, grades, home, summary });
}, [profile, grades, home, summary]);
const value = useMemo<AppData>(
() => ({
profile,