Unverified Commit 73f4c858 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

fix(panel): 切换服务器或用户时不再先显示上一个对象的数据,详情页随路由参数重新挂载,列表翻页与筛选保留旧行直到新结果到达

parent f156e385
Loading
Loading
Loading
Loading
+43 −0
Changes for panel/src/components/ValidParam.test.tsx: 43 added lines, 0 removed lines.
Original line number Diff line number Diff line
// @vitest-environment jsdom
import { describe, it, expect } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { Link, MemoryRouter, Route, Routes } from "react-router-dom";
import { ValidParam } from "./ValidParam";

// A page with state of its own, as a console's half-typed command is.
function Page() {
  return (
    <>
      <input aria-label="command" />
      <Link to="/servers/beta">beta</Link>
    </>
  );
}

function renderAt(path: string) {
  return render(
    <MemoryRouter initialEntries={[path]}>
      <Routes>
        <Route path="servers/:name" element={<ValidParam param="name" pattern={/^[a-z]+$/} />}>
          <Route index element={<Page />} />
        </Route>
      </Routes>
    </MemoryRouter>,
  );
}

describe("ValidParam", () => {
  it("starts the page afresh when the parameter changes", () => {
    renderAt("/servers/alpha");
    fireEvent.change(screen.getByLabelText("command"), { target: { value: "stop" } });

    fireEvent.click(screen.getByRole("link", { name: "beta" }));
    expect((screen.getByLabelText("command") as HTMLInputElement).value).toBe("");
  });

  it("says not found for a parameter of the wrong shape", () => {
    renderAt("/servers/Alpha..");
    expect(screen.queryByLabelText("command")).toBeNull();
    expect(screen.getByText("Page not found")).toBeTruthy();
  });
});
+3 −1
Changes for panel/src/components/ValidParam.tsx: 3 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -3,8 +3,10 @@ import { NotFound } from "@/components/States";

// ValidParam is a layout route that renders its children only when one URL
// parameter has the expected shape (lib/params.ts), and "not found" otherwise.
// A new value remounts the page below, so /servers/a → /servers/b drops a's
// half-typed input, open dialog and log buffer along with its data.
export function ValidParam({ param, pattern }: { param: string; pattern: RegExp }) {
  const value = useParams()[param];
  if (value === undefined || !pattern.test(value)) return <NotFound />;
  return <Outlet />;
  return <Outlet key={value} />;
}
+102 −0
Changes for panel/src/lib/hooks.test.tsx: 102 added lines, 0 removed lines.
Original line number Diff line number Diff line
// @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
Changes for panel/src/lib/hooks.ts: 44 added lines, 15 removed lines.
Original line number Diff line number Diff line
@@ -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
+1 −1
Changes for panel/src/pages/admin/ImageBuildPage.tsx: 1 added line, 1 removed line.
Original line number Diff line number Diff line
@@ -161,7 +161,7 @@ export function ImageBuildPage() {
    error: listError,
    loading: loadingBuilds,
    reload: reloadBuilds,
  } = useAsync(listBuilds, [listBuilds]);
  } = useAsync(listBuilds, [listBuilds], { keepPrevious: true });
  const builds = useMemo(() => buildPage?.builds ?? [], [buildPage]);
  const total = buildPage?.total ?? 0;

Loading