162 lines
5.0 KiB
TypeScript
162 lines
5.0 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
||
import { PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache';
|
||
import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh';
|
||
import {
|
||
getSessionSnapshot,
|
||
setSessionSnapshot,
|
||
deleteSessionSnapshot,
|
||
} from '../utils/sessionPageCache';
|
||
|
||
type Fetcher<T> = () => Promise<T>;
|
||
|
||
/**
|
||
* 会话快照数据源:有缓存则跳过请求;无缓存时保留上一份画面直到新数据返回。
|
||
* 手机下拉软刷新 commit / PAGE_FORCE_REFRESH_EVENT 会应用新快照。
|
||
*/
|
||
function isEmptyArray(value: unknown): boolean {
|
||
return Array.isArray(value) && value.length === 0;
|
||
}
|
||
|
||
export function useSessionResource<T>(
|
||
key: string | null,
|
||
fetcher: Fetcher<T>,
|
||
opts?: {
|
||
enabled?: boolean;
|
||
onError?: (e: unknown) => void;
|
||
/** 缓存为空数组时仍后台再拉,避免启动竞态假空粘死 */
|
||
revalidateEmpty?: boolean;
|
||
},
|
||
) {
|
||
const enabled = opts?.enabled !== false;
|
||
const revalidateEmpty = opts?.revalidateEmpty === true;
|
||
const fetcherRef = useRef(fetcher);
|
||
fetcherRef.current = fetcher;
|
||
const onErrorRef = useRef(opts?.onError);
|
||
onErrorRef.current = opts?.onError;
|
||
|
||
const [data, setData] = useState<T | undefined>(() =>
|
||
key && enabled ? getSessionSnapshot<T>(key) : undefined,
|
||
);
|
||
const [loading, setLoading] = useState(() => {
|
||
if (!key || !enabled) return false;
|
||
return getSessionSnapshot<T>(key) === undefined;
|
||
});
|
||
const [pending, setPending] = useState(false);
|
||
const seqRef = useRef(0);
|
||
const dataRef = useRef(data);
|
||
dataRef.current = data;
|
||
|
||
useEffect(() => {
|
||
if (!key || !enabled) {
|
||
if (!enabled) {
|
||
setLoading(false);
|
||
setPending(false);
|
||
}
|
||
return;
|
||
}
|
||
|
||
const hit = getSessionSnapshot<T>(key);
|
||
if (hit !== undefined && !(revalidateEmpty && isEmptyArray(hit))) {
|
||
setData(hit);
|
||
setLoading(false);
|
||
setPending(false);
|
||
return;
|
||
}
|
||
|
||
const seq = ++seqRef.current;
|
||
const keep = dataRef.current !== undefined || (hit !== undefined && isEmptyArray(hit));
|
||
if (hit !== undefined && isEmptyArray(hit)) {
|
||
setData(hit);
|
||
setPending(true);
|
||
setLoading(false);
|
||
} else if (keep && dataRef.current !== undefined) {
|
||
setPending(true);
|
||
} else {
|
||
setLoading(true);
|
||
}
|
||
|
||
fetcherRef.current()
|
||
.then((next) => {
|
||
if (seq !== seqRef.current) return;
|
||
setSessionSnapshot(key, next);
|
||
setData(next);
|
||
})
|
||
.catch((e: unknown) => {
|
||
if (seq !== seqRef.current) return;
|
||
onErrorRef.current?.(e);
|
||
// 失败不写空快照;已有画面则保留
|
||
if (!keep) setData(undefined);
|
||
})
|
||
.finally(() => {
|
||
if (seq !== seqRef.current) return;
|
||
setLoading(false);
|
||
setPending(false);
|
||
});
|
||
|
||
return () => {
|
||
seqRef.current += 1;
|
||
};
|
||
}, [key, enabled, revalidateEmpty]);
|
||
|
||
const replace = useCallback((next: T | ((prev: T | undefined) => T)) => {
|
||
setData((prev) => {
|
||
const value = typeof next === 'function' ? (next as (p: T | undefined) => T)(prev) : next;
|
||
if (key) setSessionSnapshot(key, value);
|
||
return value;
|
||
});
|
||
}, [key]);
|
||
|
||
const invalidate = useCallback(() => {
|
||
if (key) deleteSessionSnapshot(key);
|
||
}, [key]);
|
||
|
||
useEffect(() => {
|
||
const reload = (opts: { allowLoading: boolean }) => {
|
||
if (!key || !enabled) return;
|
||
const warm = getSessionSnapshot<T>(key);
|
||
if (warm !== undefined && !(revalidateEmpty && isEmptyArray(warm))) {
|
||
setData(warm);
|
||
setLoading(false);
|
||
setPending(false);
|
||
return;
|
||
}
|
||
const seq = ++seqRef.current;
|
||
const keep = dataRef.current !== undefined || (warm !== undefined && isEmptyArray(warm));
|
||
if (opts.allowLoading) {
|
||
deleteSessionSnapshot(key);
|
||
if (keep) setPending(true);
|
||
else setLoading(true);
|
||
} else if (warm !== undefined && isEmptyArray(warm)) {
|
||
setPending(true);
|
||
}
|
||
fetcherRef.current()
|
||
.then((next) => {
|
||
if (seq !== seqRef.current) return;
|
||
setSessionSnapshot(key, next);
|
||
setData(next);
|
||
})
|
||
.catch((e: unknown) => {
|
||
if (seq !== seqRef.current) return;
|
||
onErrorRef.current?.(e);
|
||
if (opts.allowLoading && !keep) setData(undefined);
|
||
})
|
||
.finally(() => {
|
||
if (seq !== seqRef.current) return;
|
||
setLoading(false);
|
||
setPending(false);
|
||
});
|
||
};
|
||
const onForce = () => reload({ allowLoading: true });
|
||
// 软刷新 commit:未命中不卸 UI,后台静默再拉
|
||
const onCommit = () => reload({ allowLoading: false });
|
||
window.addEventListener(PAGE_FORCE_REFRESH_EVENT, onForce);
|
||
window.addEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit);
|
||
return () => {
|
||
window.removeEventListener(PAGE_FORCE_REFRESH_EVENT, onForce);
|
||
window.removeEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit);
|
||
};
|
||
}, [key, enabled, revalidateEmpty]);
|
||
|
||
return { data, loading, pending, replace, invalidate };
|
||
}
|