Initial commit
This commit is contained in:
43
web/src/hooks/usePollingResource.test.tsx
Normal file
43
web/src/hooks/usePollingResource.test.tsx
Normal 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"));
|
||||
});
|
||||
});
|
||||
150
web/src/hooks/usePollingResource.ts
Normal file
150
web/src/hooks/usePollingResource.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user