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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user