fix(panel): 切换服务器或用户时不再先显示上一个对象的数据,详情页随路由参数重新挂载,列表翻页与筛选保留旧行直到新结果到达
This commit is contained in:
6 files changed
+194
-18
No files matched your search
@@ -0,0 +1,102 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { useAsync, type AsyncOptions } from "./hooks";
|
||||
|
||||
// A request the test settles by hand, one per call, in call order.
|
||||
function requests() {
|
||||
const pending: { id: string; resolve: (v: string) => void; reject: (e: unknown) => void }[] = [];
|
||||
const fetch = (id: string) =>
|
||||
new Promise<string>((resolve, reject) => pending.push({ id, resolve, reject }));
|
||||
const settle = async (i: number, how: { ok: string } | { fail: unknown }) => {
|
||||
await act(async () => {
|
||||
if ("ok" in how) pending[i].resolve(how.ok);
|
||||
else pending[i].reject(how.fail);
|
||||
});
|
||||
};
|
||||
return { pending, fetch, settle };
|
||||
}
|
||||
|
||||
// Every render's (id, data, error, loading), so a stale value shown for a
|
||||
// single render between new deps and the reload cannot slip past.
|
||||
function mount(opts?: AsyncOptions) {
|
||||
const req = requests();
|
||||
const seen: { id: string; data: string | null; error: unknown; loading: boolean }[] = [];
|
||||
const hook = renderHook(
|
||||
({ id }: { id: string }) => {
|
||||
const r = useAsync(() => req.fetch(id), [id], opts);
|
||||
seen.push({ id, data: r.data, error: r.error, loading: r.loading });
|
||||
return r;
|
||||
},
|
||||
{ initialProps: { id: "a" } },
|
||||
);
|
||||
return { ...req, ...hook, seen };
|
||||
}
|
||||
|
||||
describe("useAsync", () => {
|
||||
it("never shows one id's result while another id loads", async () => {
|
||||
const h = mount();
|
||||
await h.settle(0, { ok: "server a" });
|
||||
expect(h.result.current.data).toBe("server a");
|
||||
|
||||
h.rerender({ id: "b" });
|
||||
const forB = h.seen.filter((s) => s.id === "b");
|
||||
expect(forB.length).toBeGreaterThan(0);
|
||||
expect(forB.filter((s) => s.data !== null || !s.loading)).toEqual([]);
|
||||
|
||||
await h.settle(1, { ok: "server b" });
|
||||
expect(h.result.current).toMatchObject({ data: "server b", loading: false });
|
||||
});
|
||||
|
||||
it("never shows one id's error for another id", async () => {
|
||||
const h = mount();
|
||||
await h.settle(0, { fail: new Error("a is gone") });
|
||||
expect(h.result.current.error).toBeInstanceOf(Error);
|
||||
|
||||
h.rerender({ id: "b" });
|
||||
const forB = h.seen.filter((s) => s.id === "b");
|
||||
expect(forB.length).toBeGreaterThan(0);
|
||||
expect(forB.filter((s) => s.error !== null)).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the data through a reload of the same id", async () => {
|
||||
const h = mount();
|
||||
await h.settle(0, { ok: "first read" });
|
||||
|
||||
act(() => h.result.current.reload());
|
||||
expect(h.result.current).toMatchObject({ data: "first read", loading: true });
|
||||
|
||||
await h.settle(1, { ok: "second read" });
|
||||
expect(h.result.current).toMatchObject({ data: "second read", loading: false });
|
||||
});
|
||||
|
||||
it("keeps the data and reports the error when a reload of the same id fails", async () => {
|
||||
const h = mount();
|
||||
await h.settle(0, { ok: "first read" });
|
||||
|
||||
act(() => h.result.current.reload());
|
||||
await h.settle(1, { fail: new Error("blip") });
|
||||
expect(h.result.current.data).toBe("first read");
|
||||
expect(h.result.current.error).toBeInstanceOf(Error);
|
||||
expect(h.result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
it("with keepPrevious keeps the last rows until the new ones arrive", async () => {
|
||||
const h = mount({ keepPrevious: true });
|
||||
await h.settle(0, { ok: "page 1" });
|
||||
|
||||
h.rerender({ id: "b" });
|
||||
expect(h.result.current).toMatchObject({ data: "page 1", loading: true });
|
||||
|
||||
await h.settle(1, { ok: "page 2" });
|
||||
expect(h.result.current).toMatchObject({ data: "page 2", loading: false });
|
||||
});
|
||||
|
||||
it("drops a slow answer for an id it has moved past", async () => {
|
||||
const h = mount();
|
||||
h.rerender({ id: "b" });
|
||||
await h.settle(1, { ok: "server b" });
|
||||
await h.settle(0, { ok: "server a" });
|
||||
expect(h.result.current.data).toBe("server b");
|
||||
});
|
||||
});
|
||||
+44
-15
@@ -25,12 +25,33 @@ export interface AsyncState<T> {
|
||||
reload: () => void;
|
||||
}
|
||||
|
||||
export interface AsyncOptions {
|
||||
/** Keep showing the last result while new deps load, for a list whose deps
|
||||
* are its filter or page: the old rows stay put (each still acts on its own
|
||||
* item) instead of blanking to a spinner on every keystroke. */
|
||||
keepPrevious?: boolean;
|
||||
}
|
||||
|
||||
interface Settled<T> {
|
||||
/** The producer this result came from; a new one means new deps. */
|
||||
run: () => Promise<T>;
|
||||
data: T | null;
|
||||
error: unknown;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
/** useAsync runs an async producer on mount and on demand, guarding against
|
||||
* setState-after-unmount and out-of-order responses. */
|
||||
export function useAsync<T>(fn: () => Promise<T>, deps: unknown[] = []): AsyncState<T> {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [error, setError] = useState<unknown>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
* setState-after-unmount and out-of-order responses. A reload with the same
|
||||
* deps (polling, after a mutation) keeps the current data while it runs. New
|
||||
* deps start from nothing: the result of /servers/a is never shown as
|
||||
* /servers/b, whose buttons already act on b, not even for the one render
|
||||
* before the new request starts. */
|
||||
export function useAsync<T>(
|
||||
fn: () => Promise<T>,
|
||||
deps: unknown[] = [],
|
||||
{ keepPrevious = false }: AsyncOptions = {},
|
||||
): AsyncState<T> {
|
||||
const [settled, setSettled] = useState<Settled<T> | null>(null);
|
||||
const seq = useRef(0);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -38,23 +59,23 @@ export function useAsync<T>(fn: () => Promise<T>, deps: unknown[] = []): AsyncSt
|
||||
|
||||
const reload = useCallback(() => {
|
||||
const ticket = ++seq.current;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSettled((s) => ({
|
||||
run,
|
||||
data: s?.run === run || keepPrevious ? (s?.data ?? null) : null,
|
||||
error: null,
|
||||
loading: true,
|
||||
}));
|
||||
run().then(
|
||||
(d) => {
|
||||
if (ticket === seq.current) {
|
||||
setData(d);
|
||||
setLoading(false);
|
||||
}
|
||||
if (ticket === seq.current) setSettled({ run, data: d, error: null, loading: false });
|
||||
},
|
||||
(e) => {
|
||||
if (ticket === seq.current) {
|
||||
setError(e);
|
||||
setLoading(false);
|
||||
setSettled((s) => ({ run, data: s?.data ?? null, error: e, loading: false }));
|
||||
}
|
||||
},
|
||||
);
|
||||
}, [run]);
|
||||
}, [run, keepPrevious]);
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
@@ -65,7 +86,15 @@ export function useAsync<T>(fn: () => Promise<T>, deps: unknown[] = []): AsyncSt
|
||||
};
|
||||
}, [reload]);
|
||||
|
||||
return { data, error, loading, reload };
|
||||
// Checked at render: between new deps and the effect that reloads them the
|
||||
// state still holds the old producer's result.
|
||||
const current = settled?.run === run;
|
||||
return {
|
||||
data: current || keepPrevious ? (settled?.data ?? null) : null,
|
||||
error: current ? settled.error : null,
|
||||
loading: current ? settled.loading : true,
|
||||
reload,
|
||||
};
|
||||
}
|
||||
|
||||
/** useUnsavedGuard asks the browser to confirm leaving the page (reload, tab
|
||||
|
||||
Reference in new issue
Block a user