Initial commit

This commit is contained in:
wangxuming
2026-07-12 15:53:24 +08:00
commit 68d61700d5
252 changed files with 23291 additions and 0 deletions

View File

@@ -0,0 +1,43 @@
import { act, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { usePollingResource } from "./usePollingResource";
function Harness({ resourceKey, load }: { resourceKey: string; load: (key: string, signal: AbortSignal) => Promise<string> }) {
const resource = usePollingResource((signal) => load(resourceKey, signal), { resourceKey, intervalMs: 60_000 });
return <div><span data-testid="data">{resource.data ?? "none"}</span><span data-testid="loading">{String(resource.loading)}</span></div>;
}
describe("usePollingResource resource identity", () => {
it("clears old data and returns to loading when the resource key changes", async () => {
let resolveB!: (value: string) => void;
const load = vi.fn((key: string) => key === "A"
? Promise.resolve("project A")
: new Promise<string>((resolve) => { resolveB = resolve; }));
const view = render(<Harness resourceKey="A" load={load} />);
await waitFor(() => expect(screen.getByTestId("data")).toHaveTextContent("project A"));
view.rerender(<Harness resourceKey="B" load={load} />);
expect(screen.getByTestId("data")).toHaveTextContent("none");
expect(screen.getByTestId("loading")).toHaveTextContent("true");
await act(async () => resolveB("project B"));
await waitFor(() => expect(screen.getByTestId("data")).toHaveTextContent("project B"));
});
it("aborts the in-flight request for the previous key", async () => {
let signalA: AbortSignal | undefined;
const load = vi.fn((key: string, signal: AbortSignal) => {
if (key === "A") {
signalA = signal;
return new Promise<string>(() => undefined);
}
return Promise.resolve("project B");
});
const view = render(<Harness resourceKey="A" load={load} />);
await waitFor(() => expect(signalA).toBeDefined());
view.rerender(<Harness resourceKey="B" load={load} />);
expect(signalA?.aborted).toBe(true);
await waitFor(() => expect(screen.getByTestId("data")).toHaveTextContent("project B"));
});
});

View File

@@ -0,0 +1,150 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { ApiError } from "../api";
type ResourceKey = string | number;
interface ResourceState<T> {
resourceKey: ResourceKey;
data?: T;
loading: boolean;
refreshing: boolean;
error: ApiError | null;
offline: boolean;
lastClientSuccessAt: string | null;
}
function browserIsOffline(): boolean {
return typeof navigator !== "undefined" && !navigator.onLine;
}
function isAbortError(error: unknown): boolean {
return error instanceof Error && error.name === "AbortError";
}
export function usePollingResource<T>(
loader: (signal: AbortSignal) => Promise<T>,
options: { enabled?: boolean; intervalMs?: number; resourceKey?: ResourceKey } = {},
) {
const { enabled = true, intervalMs = 5_000, resourceKey = "default" } = options;
const loaderRef = useRef({ resourceKey, loader });
loaderRef.current = { resourceKey, loader };
const mountedRef = useRef(false);
const requestRef = useRef(0);
const controllerRef = useRef<AbortController | null>(null);
const [state, setState] = useState<ResourceState<T>>({
resourceKey,
loading: enabled,
refreshing: false,
error: null,
offline: browserIsOffline(),
lastClientSuccessAt: null,
});
const refresh = useCallback(async (initial = false) => {
if (!enabled || loaderRef.current.resourceKey !== resourceKey) return;
controllerRef.current?.abort();
const controller = new AbortController();
controllerRef.current = controller;
const requestId = ++requestRef.current;
setState((current) => {
const sameResource = current.resourceKey === resourceKey;
return {
resourceKey,
data: sameResource ? current.data : undefined,
loading: initial || !sameResource || !current.data,
refreshing: !initial && sameResource && Boolean(current.data),
error: sameResource ? current.error : null,
offline: sameResource ? current.offline : browserIsOffline(),
lastClientSuccessAt: sameResource ? current.lastClientSuccessAt : null,
};
});
try {
const data = await loaderRef.current.loader(controller.signal);
if (!mountedRef.current || controller.signal.aborted || requestId !== requestRef.current) return;
setState({
resourceKey,
data,
loading: false,
refreshing: false,
error: null,
offline: false,
lastClientSuccessAt: new Date().toISOString(),
});
} catch (error) {
if (controller.signal.aborted || isAbortError(error)) return;
if (!mountedRef.current || requestId !== requestRef.current) return;
const apiError = error instanceof ApiError ? error : new ApiError("暂时无法更新数据。", 0, error);
setState((current) => current.resourceKey !== resourceKey ? current : {
...current,
loading: false,
refreshing: false,
error: apiError,
offline: browserIsOffline() || apiError.status === 0,
});
} finally {
if (controllerRef.current === controller) controllerRef.current = null;
}
}, [enabled, resourceKey]);
useEffect(() => {
mountedRef.current = true;
requestRef.current += 1;
controllerRef.current?.abort();
controllerRef.current = null;
setState({
resourceKey,
data: undefined,
loading: enabled,
refreshing: false,
error: null,
offline: browserIsOffline(),
lastClientSuccessAt: null,
});
if (!enabled) {
return () => {
mountedRef.current = false;
requestRef.current += 1;
controllerRef.current?.abort();
};
}
void refresh(true);
const tick = () => {
if (document.visibilityState === "visible") void refresh(false);
};
const timer = window.setInterval(tick, intervalMs);
const handleOnline = () => void refresh(false);
const handleOffline = () => setState((current) => current.resourceKey === resourceKey ? { ...current, offline: true } : current);
const handleVisibility = () => {
if (document.visibilityState === "visible") void refresh(false);
};
window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);
document.addEventListener("visibilitychange", handleVisibility);
return () => {
mountedRef.current = false;
requestRef.current += 1;
controllerRef.current?.abort();
controllerRef.current = null;
window.clearInterval(timer);
window.removeEventListener("online", handleOnline);
window.removeEventListener("offline", handleOffline);
document.removeEventListener("visibilitychange", handleVisibility);
};
}, [enabled, intervalMs, refresh, resourceKey]);
const isCurrentResource = state.resourceKey === resourceKey;
return {
...state,
data: isCurrentResource ? state.data : undefined,
loading: enabled && (!isCurrentResource || state.loading),
refreshing: isCurrentResource && state.refreshing,
error: isCurrentResource ? state.error : null,
lastClientSuccessAt: isCurrentResource ? state.lastClientSuccessAt : null,
refresh: () => refresh(false),
};
}