desktop reads everything by itself: one background read scheduler (4 per store, starts spaced per account, App Store Connect X-Rate-Limit slows it below 20 % and pauses at 100 left, backoff with jitter on 429 / RESOURCE_EXHAUSTED, visible app first, one read per key), per-method TTLs over the saved reads, Settings auto-read (on) and interval (15 min, paused while hidden), Refresh all, a refresh icon and 'read 3 min ago' / 'couldn't refresh' per card, writes invalidate, status line with progress and quota; quota reaches the window through ToolResult.quota (cli/lib/quota.ts); Health top issues open with versions, sample stack (health --issue / --signature, read health_issue), Open in Play Console, Copy for AI and likely-cause hints (cli/lib/crash-hints.ts); tests/scheduler.test.ts, tests/quota-crash-hints.test.ts, boot check auto-read case
This commit is contained in:
parent
89256f1e80
commit
0fb4ce0001
46 changed files with 1531 additions and 141 deletions
|
|
@ -13,6 +13,8 @@ import { crashCrossed, newLowReviews, releaseChanges } from './lib/notify';
|
|||
import { setCrashContext } from './lib/crash';
|
||||
import { normalizeAccounts, normalizeProjects, normalizeWorkspace } from './lib/normalize';
|
||||
import { keepLines, progressOf } from './lib/progress';
|
||||
import { isStale, lanesOf, looksThrottled, oneLine, readKey, readsFor } from './lib/freshness';
|
||||
import { type Outcome, type Priority, ReadScheduler, type ReadTask, type SchedulerStats } from './lib/scheduler';
|
||||
import { fixHint } from './lib/views';
|
||||
import { failureText, usable } from './lib/results';
|
||||
|
||||
|
|
@ -32,6 +34,31 @@ export interface Toast {
|
|||
|
||||
export interface AccountHealth { check?: AccountCheck; error?: string; at: string }
|
||||
|
||||
/** What the read bus tells the cards showing one read (key: lib/freshness.ts readKey). */
|
||||
export type ReadEvent = { kind: 'start' } | { kind: 'done'; result: ToolResult } | { kind: 'error'; error: string } | { kind: 'invalidate' };
|
||||
|
||||
/** The background reader's state for the status line. */
|
||||
export interface ReaderState extends SchedulerStats { autoRead: boolean }
|
||||
|
||||
/** Throttling and App Store Connect quota of one finished read (the ToolResult's `quota`, else its text). */
|
||||
export function classifyRead(result: unknown, error: unknown, task: ReadTask<unknown>): Outcome {
|
||||
const r = result as ToolResult | undefined;
|
||||
const q = r?.quota;
|
||||
const out: Outcome = {};
|
||||
if (q?.store === 'apple' && typeof q.limit === 'number' && typeof q.remaining === 'number')
|
||||
out.quota = { credential: task.credentials.find(c => c.startsWith('apple:')) ?? 'apple:default', limit: q.limit, remaining: q.remaining };
|
||||
if (r && !r.ok && (q?.throttled || looksThrottled(r.output))) out.throttled = true;
|
||||
if (error !== undefined && looksThrottled(errorText(error))) out.throttled = true;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Which stores an app has (for the reads it gets and the lanes they take). */
|
||||
export function platformsOf(a: UnifiedApp | undefined, status?: AppStatus): { ios: boolean; android: boolean } {
|
||||
if (!a) return { ios: false, android: false };
|
||||
const pl = status?.app?.platforms as { ios?: unknown; android?: unknown } | undefined;
|
||||
return { ios: !!a.ios || !!pl?.ios || (!!a.project?.appleAccount && !pl), android: !!a.android || !!pl?.android || (!!a.project?.playAccount && !pl) };
|
||||
}
|
||||
|
||||
export interface Data {
|
||||
ready: boolean;
|
||||
info: AppInfo | null;
|
||||
|
|
@ -56,7 +83,20 @@ export interface Data {
|
|||
portfolioError: string | null;
|
||||
importBusy: boolean;
|
||||
icons: Record<string, string | null>;
|
||||
refreshPortfolio: (live?: boolean) => Promise<void>;
|
||||
/** Background reader: progress and the App Store Connect quota seen this hour. */
|
||||
reader: ReaderState;
|
||||
/** Runs one read through the scheduler (deduped, rate limited); the result also goes to the read bus. */
|
||||
schedule: (target: string, method: string, args: Record<string, unknown>, priority: Priority) => Promise<ToolResult>;
|
||||
/** Listens to one read's bus key. */
|
||||
onRead: (key: string, fn: (e: ReadEvent) => void) => () => void;
|
||||
isReading: (key: string) => boolean;
|
||||
/** Reads everything of every app that is past its TTL (force: everything). */
|
||||
readAll: (force?: boolean) => Promise<void>;
|
||||
/** After a write: the app's reads are read again. */
|
||||
invalidate: (target: string) => void;
|
||||
/** The app on screen (its reads go first). */
|
||||
setFocus: (key: string | null) => void;
|
||||
refreshPortfolio: (live?: boolean, quiet?: boolean) => Promise<void>;
|
||||
/** Reads the apps of one account (or all) from the stores. */
|
||||
importApps: (account?: string) => Promise<void>;
|
||||
checkAccount: (id: string) => Promise<void>;
|
||||
|
|
@ -155,10 +195,10 @@ export function DataProvider({ children }: { children: ReactNode }) {
|
|||
const appsRef = useRef(apps);
|
||||
appsRef.current = apps;
|
||||
|
||||
const refreshPortfolio = useCallback(async (liveRead = false) => {
|
||||
const refreshPortfolio = useCallback(async (liveRead = false, quiet = false) => {
|
||||
const job = newJob(liveRead ? 'portfolio-live' : 'portfolio');
|
||||
setBusy(liveRead ? 'live' : 'offline'); setPortfolioError(null);
|
||||
const tid = liveRead ? toast({ id: job, kind: 'job', title: t('toast.readingStores'), detail: t('toast.readingStoresDetail'), progress: null, job }) : null;
|
||||
const tid = liveRead && !quiet ? toast({ id: job, kind: 'job', title: t('toast.readingStores'), detail: t('toast.readingStoresDetail'), progress: null, job }) : null;
|
||||
try {
|
||||
const stores = liveRead ? appsRef.current.filter(a => !a.project && !a.ignored).map(a => a.target) : [];
|
||||
const r = await api.portfolio(liveRead, job, stores);
|
||||
|
|
@ -179,6 +219,78 @@ export function DataProvider({ children }: { children: ReactNode }) {
|
|||
} finally { setBusy(null); }
|
||||
}, [toast, updateToast]);
|
||||
|
||||
// ---- background reader: one scheduler for every store read of the window (lib/scheduler.ts) ----
|
||||
const schedRef = useRef<ReadScheduler | null>(null);
|
||||
if (!schedRef.current) schedRef.current = new ReadScheduler({ classify: classifyRead, limits: { apple: settings.refresh.parallel, play: settings.refresh.parallel } });
|
||||
const sched = schedRef.current;
|
||||
const [reader, setReader] = useState<SchedulerStats>(() => sched.stats());
|
||||
useEffect(() => {
|
||||
let tm: ReturnType<typeof setTimeout> | null = null;
|
||||
const un = sched.subscribe(() => { if (!tm) tm = setTimeout(() => { tm = null; setReader(sched.stats()); }, 150); });
|
||||
return () => { un(); if (tm) clearTimeout(tm); };
|
||||
}, [sched]);
|
||||
useEffect(() => { sched.setLimits({ apple: settings.refresh.parallel, play: settings.refresh.parallel }); }, [sched, settings.refresh.parallel]);
|
||||
const bus = useRef(new Map<string, Set<(e: ReadEvent) => void>>());
|
||||
const emitRead = useCallback((key: string, e: ReadEvent) => { for (const f of bus.current.get(key) ?? []) { try { f(e); } catch { /* a card's listener never breaks the bus */ } } }, []);
|
||||
const onRead = useCallback((key: string, fn: (e: ReadEvent) => void) => {
|
||||
const set = bus.current.get(key) ?? new Set();
|
||||
set.add(fn); bus.current.set(key, set);
|
||||
return () => { set.delete(fn); if (!set.size) bus.current.delete(key); };
|
||||
}, []);
|
||||
const isReading = useCallback((key: string) => sched.has(key), [sched]);
|
||||
const focusRef = useRef<string | null>(null);
|
||||
const setFocus = useCallback((key: string | null) => { focusRef.current = key; }, []);
|
||||
|
||||
const credentialsOf = (a: UnifiedApp | undefined, lanes: string[]): string[] => {
|
||||
const c: string[] = [];
|
||||
if (lanes.includes('apple')) c.push(`apple:${a?.project?.appleAccount ?? a?.ios?.account ?? 'default'}`);
|
||||
if (lanes.includes('play')) c.push(`play:${a?.project?.playAccount ?? a?.android?.account ?? 'default'}`);
|
||||
return c;
|
||||
};
|
||||
const statusRef = useRef<(a: UnifiedApp) => AppStatus | undefined>(() => undefined);
|
||||
|
||||
const schedule = useCallback((target: string, method: string, args: Record<string, unknown>, priority: Priority): Promise<ToolResult> => {
|
||||
const key = readKey(target, method, args);
|
||||
const app = appsRef.current.find(a => a.target === target);
|
||||
const pf = platformsOf(app, app ? statusRef.current(app) : undefined);
|
||||
const lanes = lanesOf(method, pf);
|
||||
const p = sched.enqueue<ToolResult>({ key, lanes, credentials: credentialsOf(app, lanes), priority, run: () => api.call<ToolResult>(method, target, { args }, newJob(method)) });
|
||||
emitRead(key, { kind: 'start' });
|
||||
p.then(result => emitRead(key, { kind: 'done', result }), e => emitRead(key, { kind: 'error', error: errorText(e) }));
|
||||
return p;
|
||||
}, [sched, emitRead]);
|
||||
|
||||
const readAll = useCallback(async (force = false) => {
|
||||
if (!force && !settingsRef.current.refresh.autoRead) return;
|
||||
if (force || isStale('portfolio.status', liveRef.current?.at)) {
|
||||
sched.enqueue({ key: '*\u001fportfolio.status', lanes: ['apple', 'play'], credentials: [], priority: 1, run: () => refreshPortfolio(true, true) }).catch(() => {});
|
||||
}
|
||||
const list = [...appsRef.current].filter(a => !a.ignored && !a.project?.missing)
|
||||
.sort((a, b) => Number(b.key === focusRef.current) - Number(a.key === focusRef.current));
|
||||
for (const a of list) {
|
||||
const pf = platformsOf(a, statusRef.current(a));
|
||||
for (const r of readsFor({ folder: !!a.project, ...pf })) {
|
||||
const key = readKey(a.target, r.method, r.args);
|
||||
if (sched.has(key)) continue;
|
||||
if (!force) {
|
||||
const c = await api.cached<ToolResult>(r.method, a.target, r.args).catch(() => null);
|
||||
if (c && !isStale(r.method, c.at)) continue;
|
||||
}
|
||||
schedule(a.target, r.method, r.args, a.key === focusRef.current ? 0 : 2).catch(() => {});
|
||||
}
|
||||
}
|
||||
}, [sched, schedule, refreshPortfolio]);
|
||||
|
||||
const invalidate = useCallback((target: string) => {
|
||||
const pre = `${target}\u001f`;
|
||||
for (const k of [...bus.current.keys()]) if (k.startsWith(pre)) emitRead(k, { kind: 'invalidate' });
|
||||
const a = appsRef.current.find(x => x.target === target);
|
||||
if (a) for (const r of readsFor({ folder: !!a.project, ...platformsOf(a, statusRef.current(a)) })) {
|
||||
const key = readKey(target, r.method, r.args);
|
||||
if (!bus.current.has(key)) schedule(target, r.method, r.args, 1).catch(() => {});
|
||||
}
|
||||
}, [emitRead, schedule]);
|
||||
|
||||
const importApps = useCallback(async (account?: string) => {
|
||||
const ids = (account ? [account] : accounts.map(a => a.id)).filter(id => account || !overrides.ignored.includes(`account:${id}`));
|
||||
if (!ids.length) return;
|
||||
|
|
@ -236,21 +348,27 @@ export function DataProvider({ children }: { children: ReactNode }) {
|
|||
|
||||
useEffect(() => { if (projects.length) refreshPortfolio(false); }, [projects.length, refreshPortfolio]);
|
||||
|
||||
// Settings > Refresh: on launch, then every N minutes (store reads use the accounts' API quota: off by default)
|
||||
// Settings > Reading: everything past its TTL is read in the background on start, when the window shows again after
|
||||
// a while, and every N minutes while it is visible (the scheduler keeps it inside the stores' limits).
|
||||
const launched = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!ready || launched.current) return;
|
||||
if (!ready || launched.current || !apps.length) return;
|
||||
launched.current = true;
|
||||
if (settings.refresh.onLaunch && (projects.length || accounts.length)) importApps().finally(() => refreshPortfolio(true));
|
||||
}, [ready, settings.refresh.onLaunch, projects.length, accounts.length, importApps, refreshPortfolio]);
|
||||
const busyRef = useRef(portfolioBusy);
|
||||
busyRef.current = portfolioBusy;
|
||||
if (settings.refresh.autoRead) readAll(false).catch(() => {});
|
||||
}, [ready, apps.length, settings.refresh.autoRead, readAll]);
|
||||
useEffect(() => {
|
||||
const m = settings.refresh.autoMinutes;
|
||||
if (!m) return;
|
||||
const tm = setInterval(() => { if (!busyRef.current) refreshPortfolio(true); }, m * 60_000);
|
||||
return () => clearInterval(tm);
|
||||
}, [settings.refresh.autoMinutes, refreshPortfolio]);
|
||||
if (!settings.refresh.autoRead) return;
|
||||
let hiddenAt = 0;
|
||||
const vis = () => {
|
||||
if (document.hidden) { hiddenAt = Date.now(); return; }
|
||||
if (hiddenAt && Date.now() - hiddenAt > 60_000) readAll(false).catch(() => {});
|
||||
hiddenAt = 0;
|
||||
};
|
||||
document.addEventListener('visibilitychange', vis);
|
||||
const m = settings.refresh.everyMinutes;
|
||||
const tm = m ? setInterval(() => { if (!document.hidden) readAll(false).catch(() => {}); }, m * 60_000) : null;
|
||||
return () => { document.removeEventListener('visibilitychange', vis); if (tm) clearInterval(tm); };
|
||||
}, [settings.refresh.autoRead, settings.refresh.everyMinutes, readAll]);
|
||||
|
||||
// icons: cached first, then read (offline, cheap)
|
||||
useEffect(() => {
|
||||
|
|
@ -296,12 +414,15 @@ export function DataProvider({ children }: { children: ReactNode }) {
|
|||
const liveFor = useCallback((a: UnifiedApp) => (a.project ? liveOf(a.project) : portfolio?.apps.find(x => x.project === a.target && x.release.source === 'live') ?? live?.value.apps.find(x => x.project === a.target)), [liveOf, portfolio, live]);
|
||||
const iconFor = useCallback((a: UnifiedApp) => (a.project ? icons[a.project.id] ?? a.storeIcon : a.storeIcon), [icons]);
|
||||
const appByKey = useCallback((key: string) => apps.find(a => a.key === key), [apps]);
|
||||
statusRef.current = statusFor;
|
||||
const readerState = useMemo<ReaderState>(() => ({ ...reader, autoRead: settings.refresh.autoRead }), [reader, settings.refresh.autoRead]);
|
||||
|
||||
const value = useMemo<Data>(() => ({
|
||||
ready, info, projects, accounts, studios, overrides, settings, storeApps, accountHealth, apps, sidecar, sidecarError, portfolio, live, portfolioBusy, portfolioError,
|
||||
importBusy, icons, refreshPortfolio, importApps, checkAccount, reload, updateOverrides, updateSettings, statusOf, liveOf, statusFor, liveFor, iconFor, appByKey,
|
||||
importBusy, icons, reader: readerState, schedule, onRead, isReading, readAll, invalidate, setFocus,
|
||||
refreshPortfolio, importApps, checkAccount, reload, updateOverrides, updateSettings, statusOf, liveOf, statusFor, liveFor, iconFor, appByKey,
|
||||
toasts, toast, updateToast, dismiss, cancelJob,
|
||||
}), [ready, info, projects, accounts, studios, overrides, settings, storeApps, accountHealth, apps, sidecar, sidecarError, portfolio, live, portfolioBusy, portfolioError,
|
||||
}), [readerState, schedule, onRead, isReading, readAll, invalidate, setFocus, ready, info, projects, accounts, studios, overrides, settings, storeApps, accountHealth, apps, sidecar, sidecarError, portfolio, live, portfolioBusy, portfolioError,
|
||||
importBusy, icons, refreshPortfolio, importApps, checkAccount, reload, updateOverrides, updateSettings, statusOf, liveOf, statusFor, liveFor, iconFor, appByKey,
|
||||
toasts, toast, updateToast, dismiss, cancelJob]);
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||
|
|
@ -309,6 +430,8 @@ export function DataProvider({ children }: { children: ReactNode }) {
|
|||
|
||||
export interface ReadState<T> {
|
||||
data: T | null;
|
||||
/** Past its TTL (lib/freshness.ts): a refresh is due or running. */
|
||||
stale?: boolean;
|
||||
/** When `data` was read (from the store or the cache). */
|
||||
at: string | null;
|
||||
fromCache: boolean;
|
||||
|
|
@ -339,7 +462,7 @@ export function useWrite(projectId: string | null) {
|
|||
const o = await api.writeWithConfirm(w.method, projectId, w.args, job);
|
||||
if (o.stage === 'dry-run') d.updateToast(tid, { kind: 'bad', title: t('write.dryFailed'), detail: withHint(failureText(o.dryRun)), progress: undefined, job: undefined });
|
||||
else if (o.stage === 'dialog') d.updateToast(tid, { kind: 'info', title: t('write.notApplied'), detail: t('write.notAppliedDetail'), progress: undefined, job: undefined });
|
||||
else if (o.result?.ok) { d.updateToast(tid, { kind: 'ok', title: w.done, detail: lastLine(o.result.output) || undefined, progress: undefined, job: undefined }); w.after?.(); }
|
||||
else if (o.result?.ok) { d.updateToast(tid, { kind: 'ok', title: w.done, detail: lastLine(o.result.output) || undefined, progress: undefined, job: undefined }); w.after?.(); d.invalidate(projectId); }
|
||||
// exit 3: the command stopped for an owner decision it did not get (a price change of a live product: the
|
||||
// Catalog tab's owner action applies it); everything else it could do was done
|
||||
else if (o.result?.exitCode === 3) { d.updateToast(tid, { kind: 'info', title: t('write.declined'), detail: lastLine(o.result.output) || undefined, progress: undefined, job: undefined }); w.after?.(); }
|
||||
|
|
@ -369,52 +492,63 @@ function notifyAfterRead(method: string, prev: unknown, next: unknown, settings:
|
|||
|
||||
/**
|
||||
* One read method of one app (a project id, or a store-only app's store target). The cached value shows at once with
|
||||
* its time; offline reads (`network: false`) then refresh by themselves, store reads only on demand (they use the
|
||||
* account's API quota). A failed read keeps the last good value and says so: never an empty "all fine".
|
||||
* its time; then, past its TTL (lib/freshness.ts), it is read again through the background scheduler (offline reads
|
||||
* always; store reads while Settings > Reading > auto-read is on). A read the background reader runs for this key
|
||||
* lands here too (the read bus). A failed read keeps the last good value and says so: never an empty "all fine".
|
||||
*/
|
||||
export function useRead<T>(target: string, method: string, o: { network: boolean; args?: Record<string, unknown>; title?: string; appName?: string; skip?: boolean }): ReadState<T> {
|
||||
const d = useData();
|
||||
const argsKey = JSON.stringify(o.args ?? {});
|
||||
const key = readKey(target, method, JSON.parse(argsKey) as Record<string, unknown>);
|
||||
const [s, set] = useState<Omit<ReadState<T>, 'refresh'>>({ data: null, at: null, fromCache: false, loading: true, error: null, command: null });
|
||||
const seq = useRef(0);
|
||||
const dataRef = useRef<T | null>(null);
|
||||
dataRef.current = s.data;
|
||||
const dRef = useRef(d);
|
||||
dRef.current = d;
|
||||
const skipRef = useRef(o.skip);
|
||||
skipRef.current = o.skip;
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const my = ++seq.current;
|
||||
const job = newJob(method);
|
||||
set(x => ({ ...x, loading: true, error: null }));
|
||||
const tid = o.network ? d.toast({ id: job, kind: 'job', title: o.title ?? t('toast.reading'), detail: t('toast.readingDetail'), progress: null, job }) : null;
|
||||
try {
|
||||
const r = await api.call<ToolResult>(method, target, { args: JSON.parse(argsKey) as Record<string, unknown> }, job);
|
||||
if (my !== seq.current) return;
|
||||
if (usable(method, r)) {
|
||||
notifyAfterRead(method, dataRef.current, r.data, d.settings, o.appName);
|
||||
set({ data: r.data as T, at: new Date().toISOString(), fromCache: false, loading: false, error: null, command: r.command });
|
||||
if (tid) d.updateToast(tid, { kind: 'ok', title: t('toast.readOk', { what: o.title ?? method }), detail: undefined, progress: undefined, job: undefined });
|
||||
} else {
|
||||
const msg = failureText(r);
|
||||
set(x => ({ ...x, loading: false, error: msg, command: r.command }));
|
||||
if (tid) d.updateToast(tid, { kind: 'bad', title: t('toast.readFailedWhat', { what: o.title ?? method }), detail: withHint(msg), progress: undefined, job: undefined });
|
||||
if (skipRef.current) return;
|
||||
set(x => ({ ...x, loading: true }));
|
||||
await dRef.current.schedule(target, method, JSON.parse(argsKey) as Record<string, unknown>, 0).catch(() => {});
|
||||
}, [target, method, argsKey]);
|
||||
|
||||
// results of this key, whoever asked for them (this card, the background reader, a write's invalidation)
|
||||
useEffect(() => {
|
||||
if (o.skip) return;
|
||||
return d.onRead(key, e => {
|
||||
if (e.kind === 'start') set(x => ({ ...x, loading: true }));
|
||||
else if (e.kind === 'invalidate') { set(x => ({ ...x, stale: true })); refresh(); }
|
||||
else if (e.kind === 'error') { if (/cancel/i.test(e.error)) set(x => ({ ...x, loading: false })); else set(x => ({ ...x, loading: false, error: oneLine(e.error) })); }
|
||||
else {
|
||||
const r = e.result;
|
||||
if (usable(method, r)) {
|
||||
notifyAfterRead(method, dataRef.current, r.data, dRef.current.settings, o.appName);
|
||||
set({ data: r.data as T, at: new Date().toISOString(), fromCache: false, loading: false, stale: false, error: null, command: r.command });
|
||||
} else set(x => ({ ...x, loading: false, error: oneLine(failureText(r)), command: r.command }));
|
||||
}
|
||||
} catch (e) {
|
||||
if (my !== seq.current) return;
|
||||
const msg = errorText(e);
|
||||
set(x => ({ ...x, loading: false, error: msg }));
|
||||
if (tid) d.updateToast(tid, { kind: /cancel/i.test(msg) ? 'info' : 'bad', title: /cancel/i.test(msg) ? t('toast.cancelled') : t('toast.readFailedWhat', { what: o.title ?? method }), detail: msg, progress: undefined, job: undefined });
|
||||
}
|
||||
}, [target, method, argsKey, o.network, o.title, o.appName, d.settings]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
});
|
||||
}, [key, o.skip]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
if (o.skip) { set({ data: null, at: null, fromCache: false, loading: false, error: null, command: null }); return; }
|
||||
const auto = !o.network || dRef.current.settings.refresh.autoRead;
|
||||
const running = dRef.current.isReading(key);
|
||||
set({ data: null, at: null, fromCache: false, loading: true, error: null, command: null });
|
||||
const go = (at: string | null) => {
|
||||
if (!alive) return;
|
||||
const due = !o.network || isStale(method, at);
|
||||
if (running) set(x => ({ ...x, loading: true }));
|
||||
else if (auto && due) refresh();
|
||||
else set(x => ({ ...x, loading: false }));
|
||||
};
|
||||
api.cached<ToolResult>(method, target, JSON.parse(argsKey) as Record<string, unknown>).then(c => {
|
||||
if (!alive) return;
|
||||
if (c && c.value.data !== undefined) set({ data: c.value.data as T, at: c.at, fromCache: true, loading: !o.network, error: null, command: c.value.command });
|
||||
else set(x => ({ ...x, loading: !o.network }));
|
||||
if (!o.network) refresh();
|
||||
}, () => { if (alive) { set(x => ({ ...x, loading: !o.network })); if (!o.network) refresh(); } });
|
||||
if (c && c.value.data !== undefined) set({ data: c.value.data as T, at: c.at, fromCache: true, loading: true, stale: isStale(method, c.at), error: null, command: c.value.command });
|
||||
go(c && c.value.data !== undefined ? c.at : null);
|
||||
}, () => go(null));
|
||||
return () => { alive = false; };
|
||||
}, [target, method, argsKey, o.skip]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue