Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions apps/gittensory-miner-ui/src/lib/use-polled-fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { useEffect, useState } from "react";

/** Shared "live refresh" cadence for the local, offline dev-server API views (#4856) — frequent enough to feel
* live for a cheap local SQLite read, without polling so tightly it's wasteful. */
export const DEFAULT_POLL_INTERVAL_MS = 10_000;

/**
* Fetch once on mount, then re-fetch on a fixed interval so newly-recorded local activity appears without a
* manual page reload (#4856). Skips overlapping ticks: if a fetch from a previous tick is still in flight when
* the next interval fires, that tick is a no-op rather than stacking concurrent requests.
*/
export function usePolledFetch<T>(loadFn: () => Promise<T>, intervalMs: number): T | null {
const [result, setResult] = useState<T | null>(null);

useEffect(() => {
let cancelled = false;
let inFlight = false;

const run = () => {
if (inFlight) return;
inFlight = true;
void loadFn()
.then((loaded) => {
if (!cancelled) setResult(loaded);
})
.finally(() => {
inFlight = false;
});
};

run();
const id = window.setInterval(run, intervalMs);
return () => {
cancelled = true;
window.clearInterval(id);
};
}, [loadFn, intervalMs]);

return result;
}
21 changes: 20 additions & 1 deletion apps/gittensory-miner-ui/src/portfolio-queue.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";

import {
emptyPortfolioQueueSummary,
Expand Down Expand Up @@ -105,6 +105,25 @@ describe("PortfolioPage (#4306)", () => {
expect(screen.getByRole("heading", { name: "Portfolio queue" })).toBeTruthy();
await waitFor(() => expect(screen.getByText("Queued", { selector: "dt" }).nextSibling?.textContent).toBe("2"));
});

describe("live refresh (#4856)", () => {
afterEach(() => {
vi.useRealTimers();
});

it("polls the injected loader again on the configured interval, without a manual page reload", async () => {
vi.useFakeTimers();
const loadPortfolioQueue = vi.fn(async (): Promise<PortfolioQueueResult> => ({
ok: true,
summary: fixtureSummary,
}));
render(<PortfolioPage loadPortfolioQueue={loadPortfolioQueue} pollIntervalMs={1000} />);

await vi.waitFor(() => expect(loadPortfolioQueue).toHaveBeenCalledTimes(1));
await vi.advanceTimersByTimeAsync(1000);
await vi.waitFor(() => expect(loadPortfolioQueue).toHaveBeenCalledTimes(2));
});
});
});

describe("fetchPortfolioQueue (#4306)", () => {
Expand Down
16 changes: 4 additions & 12 deletions apps/gittensory-miner-ui/src/routes/portfolio.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { createFileRoute } from "@tanstack/react-router";
import { useEffect, useState } from "react";

import { Card, CardContent, CardHeader } from "@jsonbored/gittensory-ui-kit/components/card";
import {
Expand All @@ -11,6 +10,7 @@ import {
TableRow,
} from "@jsonbored/gittensory-ui-kit/components/table";

import { DEFAULT_POLL_INTERVAL_MS, usePolledFetch } from "../lib/use-polled-fetch";
import { fetchPortfolioQueue, type PortfolioQueueResult, type QueueStatus } from "../lib/portfolio-queue";

export const Route = createFileRoute("/portfolio")({
Expand Down Expand Up @@ -98,20 +98,12 @@ export function PortfolioQueueView({ result }: { result: PortfolioQueueResult |

export function PortfolioPage({
loadPortfolioQueue = fetchPortfolioQueue,
pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
}: {
loadPortfolioQueue?: () => Promise<PortfolioQueueResult>;
pollIntervalMs?: number;
}) {
const [result, setResult] = useState<PortfolioQueueResult | null>(null);

useEffect(() => {
let cancelled = false;
void loadPortfolioQueue().then((loaded) => {
if (!cancelled) setResult(loaded);
});
return () => {
cancelled = true;
};
}, [loadPortfolioQueue]);
const result = usePolledFetch(loadPortfolioQueue, pollIntervalMs);

return (
<Card>
Expand Down
16 changes: 4 additions & 12 deletions apps/gittensory-miner-ui/src/routes/run-history.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { createFileRoute } from "@tanstack/react-router";
import { useEffect, useState } from "react";

import { Badge } from "@jsonbored/gittensory-ui-kit/components/badge";
import { Card, CardContent, CardHeader } from "@jsonbored/gittensory-ui-kit/components/card";
Expand All @@ -12,6 +11,7 @@ import {
TableRow,
} from "@jsonbored/gittensory-ui-kit/components/table";

import { DEFAULT_POLL_INTERVAL_MS, usePolledFetch } from "../lib/use-polled-fetch";
import { fetchRunStates, type RunHistoryResult, type RunStateRow } from "../lib/run-history";

export const Route = createFileRoute("/run-history")({
Expand Down Expand Up @@ -73,20 +73,12 @@ export function RunHistoryView({ result }: { result: RunHistoryResult | null })

export function RunHistoryPage({
loadRunStates = fetchRunStates,
pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
}: {
loadRunStates?: () => Promise<RunHistoryResult>;
pollIntervalMs?: number;
}) {
const [result, setResult] = useState<RunHistoryResult | null>(null);

useEffect(() => {
let cancelled = false;
void loadRunStates().then((loaded) => {
if (!cancelled) setResult(loaded);
});
return () => {
cancelled = true;
};
}, [loadRunStates]);
const result = usePolledFetch(loadRunStates, pollIntervalMs);

return (
<Card>
Expand Down
18 changes: 17 additions & 1 deletion apps/gittensory-miner-ui/src/run-history.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";

import { fetchRunStates, RUN_STATE_API_PATH, type RunHistoryResult, type RunStateRow } from "./lib/run-history";
import { RunHistoryPage, RunHistoryView } from "./routes/run-history";
Expand Down Expand Up @@ -44,6 +44,22 @@ describe("RunHistoryPage (#4305)", () => {
expect(screen.getByRole("heading", { name: "Run history" })).toBeTruthy();
await waitFor(() => expect(screen.getByText("acme/widgets")).toBeTruthy());
});

describe("live refresh (#4856)", () => {
afterEach(() => {
vi.useRealTimers();
});

it("polls the injected loader again on the configured interval, without a manual page reload", async () => {
vi.useFakeTimers();
const loadRunStates = vi.fn(async (): Promise<RunHistoryResult> => ({ ok: true, rows: fixtureRows }));
render(<RunHistoryPage loadRunStates={loadRunStates} pollIntervalMs={1000} />);

await vi.waitFor(() => expect(loadRunStates).toHaveBeenCalledTimes(1));
await vi.advanceTimersByTimeAsync(1000);
await vi.waitFor(() => expect(loadRunStates).toHaveBeenCalledTimes(2));
});
});
});

describe("fetchRunStates (#4305)", () => {
Expand Down
94 changes: 94 additions & 0 deletions apps/gittensory-miner-ui/src/use-polled-fetch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { renderHook, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";

import { DEFAULT_POLL_INTERVAL_MS, usePolledFetch } from "./lib/use-polled-fetch";

afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});

describe("usePolledFetch (#4856)", () => {
it("fetches once immediately on mount", async () => {
const loadFn = vi.fn(async () => "loaded");
const { result } = renderHook(() => usePolledFetch(loadFn, 1000));
await waitFor(() => expect(result.current).toBe("loaded"));
expect(loadFn).toHaveBeenCalledTimes(1);
});

it("re-fetches on every poll interval tick, updating the returned result each time", async () => {
vi.useFakeTimers();
let call = 0;
const loadFn = vi.fn(async () => `loaded-${(call += 1)}`);
const { result } = renderHook(() => usePolledFetch(loadFn, 1000));

await vi.waitFor(() => expect(result.current).toBe("loaded-1"));

await vi.advanceTimersByTimeAsync(1000);
await vi.waitFor(() => expect(result.current).toBe("loaded-2"));

await vi.advanceTimersByTimeAsync(1000);
await vi.waitFor(() => expect(result.current).toBe("loaded-3"));

expect(loadFn).toHaveBeenCalledTimes(3);
});

it("stops polling after unmount", async () => {
vi.useFakeTimers();
const loadFn = vi.fn(async () => "loaded");
const { result, unmount } = renderHook(() => usePolledFetch(loadFn, 1000));
await vi.waitFor(() => expect(result.current).toBe("loaded"));
expect(loadFn).toHaveBeenCalledTimes(1);

unmount();
await vi.advanceTimersByTimeAsync(5000);
expect(loadFn).toHaveBeenCalledTimes(1); // no further calls after unmount
});

it("skips an overlapping tick when the previous fetch is still in flight, instead of stacking concurrent requests", async () => {
vi.useFakeTimers();
let resolveFirst: ((value: string) => void) | undefined;
let callCount = 0;
const loadFn = vi.fn(() => {
callCount += 1;
if (callCount === 1) {
return new Promise<string>((resolve) => {
resolveFirst = resolve;
});
}
return Promise.resolve(`loaded-${callCount}`);
});

renderHook(() => usePolledFetch(loadFn, 1000));
expect(loadFn).toHaveBeenCalledTimes(1); // first call in flight, unresolved

// A tick fires while the first fetch is still pending -- must be skipped, not stacked.
await vi.advanceTimersByTimeAsync(1000);
expect(loadFn).toHaveBeenCalledTimes(1);

// Resolve the first fetch; the NEXT tick after that is free to fetch again.
resolveFirst?.("loaded-1");
await vi.advanceTimersByTimeAsync(0);
await vi.advanceTimersByTimeAsync(1000);
expect(loadFn).toHaveBeenCalledTimes(2);
});

it("does not update the result after unmount, even if an in-flight fetch resolves late", async () => {
let resolveLoad: ((value: string) => void) | undefined;
const loadFn = vi.fn(
() =>
new Promise<string>((resolve) => {
resolveLoad = resolve;
}),
);
const { result, unmount } = renderHook(() => usePolledFetch(loadFn, 1000));
unmount();
resolveLoad?.("too-late");
await new Promise((resolve) => setTimeout(resolve, 0));
expect(result.current).toBeNull();
});

it("exports a sensible default poll interval", () => {
expect(DEFAULT_POLL_INTERVAL_MS).toBeGreaterThan(0);
});
});
Loading