Files
MathApp_frontend/src/lib/useAsync.ts
AliReza eafee76a93 Smooth screen transitions: new screen waits hidden until its data arrives, no half-built flash
- Navigator stage: the next screen mounts hidden under the current one and is revealed only when its
  first data has arrived (ScreenGate held by useAsync/useGateHold), at most 450 ms; a thin top bar shows
  after 120 ms of waiting. Push slides from the left, back from the right, tabs only fade.
- Session cache (stale-while-revalidate) for screen GETs: revisited screens draw instantly.
- Home map and stats are fetched during the splash, so the first screen arrives complete.
- Entrance pop-ins (mascot, islands, lesson cards) run on first view only, not on every revisit.
- Shop status card shows placeholders instead of a wrong «نداری» while loading.
- Re-tapping the open tab scrolls to top instead of reloading; sheets close with a swipe down.
- Quiz: «۱ از ۴» instead of «۱/۴» (read as a fraction), varied praise, «N تا پشتِ هم درست»,
  growth-mindset copy after a weak round, zero-reward tiles hidden.
- Streak: a concrete next step when the streak is 0 or today is still empty.

Checked in headless Chrome against the local backend (frame captures at 150 ms and 700 ms latency,
rapid tab tapping, full quiz run). Not checked on a real phone or iOS Safari.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:55:47 +03:30

112 lines
5.0 KiB
TypeScript

import { createContext, useCallback, useContext, useEffect, useRef, useState, type DependencyList } from 'react';
export interface AsyncState<T> {
data: T | undefined;
error: unknown;
loading: boolean;
reload: () => void;
setData: (update: T | ((prev: T | undefined) => T | undefined)) => void;
}
/**
* کشِ همین نشست برای پاسخ‌های GET، با کلیدی که صفحه می‌دهد. صفحه‌ای که دوباره باز می‌شود (برگشت، تعویضِ تب)
* فوراً با داده‌ی قبلی رسم می‌شود و همان لحظه بی‌صدا از سرور تازه می‌شود (stale-while-revalidate). قبلاً هر بار
* اول کارتِ خالی یا «نداری» دیده می‌شد و چند صد میلی‌ثانیه بعد داده‌ی واقعی جایش می‌پرید.
* با خروج از حساب پاک می‌شود (`clearAsyncCache`)، تا داده‌ی یک کاربر به کاربرِ بعدی نرسد.
*/
const cache = new Map<string, unknown>();
export function clearAsyncCache() {
cache.clear();
}
/**
* «دروازه»ی صفحه‌ی تازه: Navigator صفحه‌ی بعدی را پنهان زیرِ صفحه‌ی فعلی می‌سازد و تا وقتی داده‌ی اولش
* نرسیده (یا سقفِ انتظار نگذشته) نشانش نمی‌دهد. هر useAsyncی که بارِ اولش بی‌کش است یک hold می‌گیرد.
* بیرون از صفحه‌ها (مثلاً جریانِ ورود) این context خالی است و کاری نمی‌کند.
*/
export interface ScreenGate {
hold: () => () => void;
}
export const ScreenGateContext = createContext<ScreenGate | null>(null);
/**
* برای صفحه‌ای که داده‌اش را بی useAsync می‌گیرد (آزمون، دستیار): تا `loading` درست است، صفحه‌ی تازه پنهان
* می‌ماند. همان قاعده‌ی useAsync: دروازه‌ی باز‌شده hold را نادیده می‌گیرد.
*/
export function useGateHold(loading: boolean) {
const gate = useContext(ScreenGateContext);
useEffect(() => (loading && gate ? gate.hold() : undefined), [loading, gate]);
}
/**
* داده‌ای که از سرور می‌آید، با حالت‌های بارگذاری/خطا و «دوباره».
* پاسخِ درخواستِ کهنه (بعد از عوض شدنِ deps) نادیده گرفته می‌شود.
* با `key`، پاسخ در کشِ نشست می‌ماند و بارِ بعد فوراً نشان داده می‌شود (بالای همین فایل).
*/
export function useAsync<T>(load: () => Promise<T>, deps: DependencyList, opts: { key?: string } = {}): AsyncState<T> {
const { key } = opts;
const [data, setDataState] = useState<T | undefined>(() => (key !== undefined ? (cache.get(key) as T | undefined) : undefined));
const [error, setError] = useState<unknown>(null);
const [loading, setLoading] = useState(() => key === undefined || !cache.has(key));
const [nonce, setNonce] = useState(0);
const loadRef = useRef(load);
loadRef.current = load;
const gate = useContext(ScreenGateContext);
useEffect(() => {
let alive = true;
const hit = key !== undefined && cache.has(key);
if (hit) {
// کلید عوض شده (مثلاً «هفتگی» ← «همیشه») و برای کلیدِ تازه کش هست: همان را نشان بده و بی‌لودر تازه کن.
setDataState(cache.get(key) as T);
setLoading(false);
} else {
setLoading(true);
}
setError(null);
// دروازه‌ای که یک بار باز شده hold را نادیده می‌گیرد؛ پس «دوباره» و تازه‌سازی صفحه را پنهان نمی‌کنند.
const release = !hit && gate ? gate.hold() : null;
loadRef.current().then(
(value) => {
if (!alive) return;
if (key !== undefined) cache.set(key, value);
setDataState(value);
setLoading(false);
release?.();
},
(err: unknown) => {
if (!alive) return;
if (hit) {
// داده‌ی کش‌شده روی صفحه است؛ شکستِ تازه‌سازی آن را با صفحه‌ی خطا عوض نمی‌کند، ولی دیده می‌شود.
console.warn(`refresh of ${key} failed; showing cached data`, err);
} else {
setError(err);
}
setLoading(false);
release?.();
},
);
return () => {
alive = false;
release?.();
};
}, [...deps, key, nonce]);
const reload = useCallback(() => setNonce((n) => n + 1), []);
const setData = useCallback(
(update: T | ((prev: T | undefined) => T | undefined)) => {
setDataState((prev) => {
const next = typeof update === 'function' ? (update as (p: T | undefined) => T | undefined)(prev) : update;
if (key !== undefined) {
if (next === undefined) cache.delete(key);
else cache.set(key, next);
}
return next;
});
},
[key],
);
return { data, error, loading, reload, setData };
}