Loading panel/src/components/ValidParam.test.tsx 0 → 100644 +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(); }); }); panel/src/components/ValidParam.tsx +3 −1 Changes for panel/src/components/ValidParam.tsx: 3 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -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} />; } panel/src/lib/hooks.test.tsx 0 → 100644 +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"); }); }); panel/src/lib/hooks.ts +44 −15 Changes for panel/src/lib/hooks.ts: 44 added lines, 15 removed lines. Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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(); Loading @@ -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 Loading panel/src/pages/admin/ImageBuildPage.tsx +1 −1 Changes for panel/src/pages/admin/ImageBuildPage.tsx: 1 added line, 1 removed line. Original line number Diff line number Diff line Loading @@ -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 Loading
panel/src/components/ValidParam.test.tsx 0 → 100644 +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(); }); });
panel/src/components/ValidParam.tsx +3 −1 Changes for panel/src/components/ValidParam.tsx: 3 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -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} />; }
panel/src/lib/hooks.test.tsx 0 → 100644 +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"); }); });
panel/src/lib/hooks.ts +44 −15 Changes for panel/src/lib/hooks.ts: 44 added lines, 15 removed lines. Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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(); Loading @@ -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 Loading
panel/src/pages/admin/ImageBuildPage.tsx +1 −1 Changes for panel/src/pages/admin/ImageBuildPage.tsx: 1 added line, 1 removed line. Original line number Diff line number Diff line Loading @@ -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