FN-8099: refresh GitHub stars in Settings

Refresh the Settings GitHub star counter while the interface is visible.

- refresh stale counts on a visibility-gated 15-minute interval
- bypass HTTP caching and prevent overlapping GitHub requests
- add Settings-surface and hook regression coverage

Files changed:
 .changeset/fn-8099-github-star-refresh.md          |   7 ++
 .../dashboard/app/components/SettingsModal.tsx     | 109 +++++++++-------
 .../components/__tests__/settings-mobile.test.tsx  |  59 ++++++++-
 .../__tests__/useGitHubStarCount.test.tsx          | 140 +++++++++++++++++++++
 4 files changed, 271 insertions(+), 44 deletions(-)

Fusion-Task-Id: FN-8099

Fusion-Task-Lineage: f93f5fe0-a1ea-48f2-b737-2fa1fbee4ed6

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-16 08:23:19 -07:00
parent e6b438e4ec
commit 0e7b86ecc4
4 changed files with 271 additions and 44 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Keep the Settings GitHub star counter up to date with a lightweight, in-view refresh.
category: fix
dev: Reworked useGitHubStarCount with visibility-gated interval refresh, cache no-store, and a 15-minute TTL.

View File

@@ -89,10 +89,10 @@ import { rankSettingsSearchResults, matchedSectionIds } from "./settings/search/
import { SettingsSearchHighlightProvider } from "./settings/SettingsSearchHighlightContext"; import { SettingsSearchHighlightProvider } from "./settings/SettingsSearchHighlightContext";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// GitHub star count — fetched once per session, cached in localStorage (1 h). // GitHub star count — cached locally and refreshed only while Settings is visible.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const GITHUB_STAR_CACHE_KEY = "fusion_github_star_count"; export const GITHUB_STAR_CACHE_KEY = "fusion_github_star_count";
const GITHUB_STAR_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour export const GITHUB_STAR_CACHE_TTL_MS = 15 * 60 * 1000;
const GITHUB_STAR_CLICKED_KEY = "fusion:github-star-clicked"; const GITHUB_STAR_CLICKED_KEY = "fusion:github-star-clicked";
function isSlashPrefixedAbsolutePath(path: string): boolean { function isSlashPrefixedAbsolutePath(path: string): boolean {
@@ -156,57 +156,80 @@ interface StarCache {
fetchedAt: number; fetchedAt: number;
} }
function useGitHubStarCount(): number | null { function readGitHubStarCache(): StarCache | null {
const [count, setCount] = useState<number | null>(() => { try {
try { const raw = localStorage.getItem(GITHUB_STAR_CACHE_KEY);
const raw = localStorage.getItem(GITHUB_STAR_CACHE_KEY); if (!raw) return null;
if (raw) {
const parsed: StarCache = JSON.parse(raw) as StarCache; const parsed: StarCache = JSON.parse(raw) as StarCache;
if (Date.now() - parsed.fetchedAt < GITHUB_STAR_CACHE_TTL_MS) { return Number.isFinite(parsed.count) && Number.isFinite(parsed.fetchedAt) ? parsed : null;
return parsed.count; } catch {
}
}
} catch {
// ignore malformed cache
}
return null; return null;
}); }
}
useEffect(() => { function isGitHubStarCacheFresh(cache: StarCache | null): boolean {
// If we already have a fresh count from the initial state, skip the fetch. return cache !== null && Date.now() - cache.fetchedAt < GITHUB_STAR_CACHE_TTL_MS;
try { }
const raw = localStorage.getItem(GITHUB_STAR_CACHE_KEY);
if (raw) { /**
const parsed: StarCache = JSON.parse(raw) as StarCache; * FNXC:SettingsGitHubStar 2026-07-16-07:47:
if (Date.now() - parsed.fetchedAt < GITHUB_STAR_CACHE_TTL_MS) { * Keep the Settings star counter current with a visibility-gated, bounded interval refresh.
return; * The hook never fetches while its interface is off-screen, disables HTTP caching to avoid stale
} * GitHub responses, and shares an in-flight guard so foreground and interval triggers cannot flood the network.
} */
} catch { export function useGitHubStarCount(): number | null {
// ignore const [count, setCount] = useState<number | null>(() => readGitHubStarCache()?.count ?? null);
const inFlightRef = useRef(false);
const refresh = useCallback(() => {
if (document.hidden || inFlightRef.current || isGitHubStarCacheFresh(readGitHubStarCache())) {
return;
} }
fetch("https://api.github.com/repos/Runfusion/Fusion") inFlightRef.current = true;
.then((res) => { void fetch("https://api.github.com/repos/Runfusion/Fusion", { cache: "no-store" })
if (!res.ok) return; .then(async (response) => {
return res.json() as Promise<{ stargazers_count?: number }>; if (!response.ok) return null;
const data = await response.json() as { stargazers_count?: unknown };
return typeof data.stargazers_count === "number" && Number.isFinite(data.stargazers_count)
? data.stargazers_count
: null;
}) })
.then((data) => { .then((starCount) => {
if (data && typeof data.stargazers_count === "number") { if (starCount === null) return;
const cache: StarCache = { count: data.stargazers_count, fetchedAt: Date.now() };
try { const cache: StarCache = { count: starCount, fetchedAt: Date.now() };
localStorage.setItem(GITHUB_STAR_CACHE_KEY, JSON.stringify(cache)); try {
} catch { localStorage.setItem(GITHUB_STAR_CACHE_KEY, JSON.stringify(cache));
// quota exceeded — just skip } catch {
} // quota exceeded — preserve the displayed value even when persistence is unavailable
setCount(data.stargazers_count);
} }
setCount(starCount);
}) })
.catch(() => { .catch(() => {
// Network failure — hide count gracefully, no update // Keep the last known value and stale cache so the next eligible trigger can retry.
})
.finally(() => {
inFlightRef.current = false;
}); });
}, []); }, []);
useEffect(() => {
refresh();
const onVisibilityChange = () => {
if (!document.hidden) refresh();
};
document.addEventListener("visibilitychange", onVisibilityChange);
const intervalId = window.setInterval(refresh, GITHUB_STAR_CACHE_TTL_MS);
return () => {
document.removeEventListener("visibilitychange", onVisibilityChange);
window.clearInterval(intervalId);
};
}, [refresh]);
return count; return count;
} }

View File

@@ -1,7 +1,7 @@
import fs from "node:fs"; import fs from "node:fs";
import { loadAllAppCss } from "../../test/cssFixture"; import { loadAllAppCss } from "../../test/cssFixture";
import path from "node:path"; import path from "node:path";
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, waitFor, within } from "@testing-library/react"; import { render, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { SettingsModal, SettingsView } from "../SettingsModal"; import { SettingsModal, SettingsView } from "../SettingsModal";
@@ -153,6 +153,11 @@ vi.mock("../../hooks/useMemoryBackendStatus", () => ({
import { fetchDashboardHealth, fetchSettings, updateSettings } from "../../api"; import { fetchDashboardHealth, fetchSettings, updateSettings } from "../../api";
function setDocumentHidden(hidden: boolean): void {
Object.defineProperty(document, "hidden", { configurable: true, value: hidden });
Object.defineProperty(document, "visibilityState", { configurable: true, value: hidden ? "hidden" : "visible" });
}
function mockSettingsViewport(matches: boolean): void { function mockSettingsViewport(matches: boolean): void {
Object.defineProperty(window, "matchMedia", { Object.defineProperty(window, "matchMedia", {
writable: true, writable: true,
@@ -221,12 +226,17 @@ function expectBaseRule(css: string, selector: string, declaration: string): voi
describe("SettingsModal mobile adaptations", () => { describe("SettingsModal mobile adaptations", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
setDocumentHidden(false);
localStorage.removeItem("fusion_github_star_count"); localStorage.removeItem("fusion_github_star_count");
localStorage.removeItem("fusion:github-star-clicked"); localStorage.removeItem("fusion:github-star-clicked");
localStorage.setItem("fusion:settings:show-advanced", "true"); localStorage.setItem("fusion:settings:show-advanced", "true");
mockSettingsViewport(false); mockSettingsViewport(false);
}); });
afterEach(() => {
vi.unstubAllGlobals();
});
it("renders mobile-targeted settings layout classes", async () => { it("renders mobile-targeted settings layout classes", async () => {
const { container } = render(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />); const { container } = render(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled()); await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
@@ -660,6 +670,53 @@ describe("SettingsModal mobile adaptations", () => {
localStorage.removeItem("fusion_github_star_count"); localStorage.removeItem("fusion_github_star_count");
}); });
it.each([
["modal", (props: { onClose: () => void; addToast: () => void }) => <SettingsModal {...props} />],
["embedded", (props: { onClose: () => void; addToast: () => void }) => <SettingsView {...props} />],
])("refreshes a stale GitHub star count in the %s Settings surface", async (_surface, Surface) => {
localStorage.setItem("fusion_github_star_count", JSON.stringify({ count: 999, fetchedAt: Date.now() - (16 * 60 * 1000) }));
let resolveGitHubFetch: ((value: Response) => void) | undefined;
const githubFetch = vi.fn(() => new Promise<Response>((resolve) => { resolveGitHubFetch = resolve; }));
vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => (
String(input).includes("api.github.com/repos/Runfusion/Fusion")
? githubFetch()
: Promise.resolve({ ok: true, json: async () => ({}) } as Response)
)));
const renderResult = render(<Surface onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
await waitFor(() => expect(githubFetch).toHaveBeenCalledTimes(1));
expect(renderResult.container.querySelector(".settings-github-star-btn__count")?.textContent).toBe("999");
resolveGitHubFetch?.({ ok: true, json: async () => ({ stargazers_count: 123 }) } as Response);
await waitFor(() => expect(renderResult.container.querySelector(".settings-github-star-btn__count")?.textContent).toBe("123"));
renderResult.unmount();
});
it.each([
["modal", (props: { onClose: () => void; addToast: () => void }) => <SettingsModal {...props} />],
["embedded", (props: { onClose: () => void; addToast: () => void }) => <SettingsView {...props} />],
])("refreshes stale GitHub stars when the hidden %s Settings surface returns", async (_surface, Surface) => {
localStorage.setItem("fusion_github_star_count", JSON.stringify({ count: 999, fetchedAt: Date.now() - (16 * 60 * 1000) }));
setDocumentHidden(true);
const githubFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ stargazers_count: 456 }) } as Response);
vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => (
String(input).includes("api.github.com/repos/Runfusion/Fusion")
? githubFetch()
: Promise.resolve({ ok: true, json: async () => ({}) } as Response)
)));
const renderResult = render(<Surface onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
expect(githubFetch).not.toHaveBeenCalled();
setDocumentHidden(false);
document.dispatchEvent(new Event("visibilitychange"));
await waitFor(() => expect(renderResult.container.querySelector(".settings-github-star-btn__count")?.textContent).toBe("456"));
expect(githubFetch).toHaveBeenCalledTimes(1);
renderResult.unmount();
});
it("contains required mobile settings CSS overrides", () => { it("contains required mobile settings CSS overrides", () => {
const css = loadAllAppCss(); const css = loadAllAppCss();

View File

@@ -0,0 +1,140 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
GITHUB_STAR_CACHE_KEY,
GITHUB_STAR_CACHE_TTL_MS,
useGitHubStarCount,
} from "../SettingsModal";
const NOW = new Date("2026-07-16T08:00:00.000Z").valueOf();
function setDocumentHidden(hidden: boolean): void {
Object.defineProperty(document, "hidden", { configurable: true, value: hidden });
Object.defineProperty(document, "visibilityState", { configurable: true, value: hidden ? "hidden" : "visible" });
}
function writeCache(count: number, fetchedAt = Date.now()): void {
localStorage.setItem(GITHUB_STAR_CACHE_KEY, JSON.stringify({ count, fetchedAt }));
}
function response(stargazers_count: unknown, ok = true): Response {
return { ok, json: vi.fn().mockResolvedValue({ stargazers_count }) } as unknown as Response;
}
describe("useGitHubStarCount", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
vi.stubGlobal("fetch", vi.fn());
localStorage.clear();
setDocumentHidden(false);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("fetches and updates when no cache exists", async () => {
vi.mocked(fetch).mockResolvedValue(response(123));
const { result } = renderHook(() => useGitHubStarCount());
expect(result.current).toBeNull();
await act(async () => {});
expect(result.current).toBe(123);
expect(fetch).toHaveBeenCalledWith("https://api.github.com/repos/Runfusion/Fusion", { cache: "no-store" });
});
it("shows a stale cache value before refreshing it", async () => {
writeCache(100, NOW - GITHUB_STAR_CACHE_TTL_MS - 1);
let resolveFetch: ((value: Response) => void) | undefined;
vi.mocked(fetch).mockImplementation(() => new Promise((resolve) => { resolveFetch = resolve; }));
const { result } = renderHook(() => useGitHubStarCount());
expect(result.current).toBe(100);
expect(fetch).toHaveBeenCalledTimes(1);
await act(async () => resolveFetch?.(response(200)));
expect(result.current).toBe(200);
});
it("does not fetch a fresh cache on mount or visibility restoration", () => {
writeCache(100);
renderHook(() => useGitHubStarCount());
expect(fetch).not.toHaveBeenCalled();
setDocumentHidden(true);
document.dispatchEvent(new Event("visibilitychange"));
setDocumentHidden(false);
document.dispatchEvent(new Event("visibilitychange"));
expect(fetch).not.toHaveBeenCalled();
});
it("waits while hidden and refreshes a cache that became stale on foreground", async () => {
writeCache(100);
setDocumentHidden(true);
vi.mocked(fetch).mockResolvedValue(response(200));
const { result } = renderHook(() => useGitHubStarCount());
await act(async () => { await vi.advanceTimersByTimeAsync(GITHUB_STAR_CACHE_TTL_MS + 1); });
expect(fetch).not.toHaveBeenCalled();
expect(result.current).toBe(100);
setDocumentHidden(false);
document.dispatchEvent(new Event("visibilitychange"));
await act(async () => {});
expect(result.current).toBe(200);
expect(fetch).toHaveBeenCalledTimes(1);
});
it("refreshes at most once per bounded interval and prevents overlapping requests", async () => {
vi.mocked(fetch).mockResolvedValueOnce(response(100));
const { result } = renderHook(() => useGitHubStarCount());
await act(async () => {});
expect(result.current).toBe(100);
vi.setSystemTime(NOW + GITHUB_STAR_CACHE_TTL_MS + 1);
let resolveFetch: ((value: Response) => void) | undefined;
vi.mocked(fetch).mockImplementation(() => new Promise((resolve) => { resolveFetch = resolve; }));
await act(async () => { await vi.advanceTimersByTimeAsync(GITHUB_STAR_CACHE_TTL_MS); });
document.dispatchEvent(new Event("visibilitychange"));
document.dispatchEvent(new Event("visibilitychange"));
expect(fetch).toHaveBeenCalledTimes(2);
await act(async () => resolveFetch?.(response(200)));
expect(result.current).toBe(200);
await act(async () => { await vi.advanceTimersByTimeAsync(GITHUB_STAR_CACHE_TTL_MS); });
expect(fetch).toHaveBeenCalledTimes(3);
});
it("preserves stale cache and retries after failed or malformed responses", async () => {
writeCache(100, NOW - GITHUB_STAR_CACHE_TTL_MS - 1);
vi.mocked(fetch).mockResolvedValueOnce(response(0, false)).mockResolvedValueOnce(response("invalid"));
const { result } = renderHook(() => useGitHubStarCount());
await act(async () => {});
expect(result.current).toBe(100);
expect(JSON.parse(localStorage.getItem(GITHUB_STAR_CACHE_KEY) ?? "{}")).toEqual({ count: 100, fetchedAt: NOW - GITHUB_STAR_CACHE_TTL_MS - 1 });
document.dispatchEvent(new Event("visibilitychange"));
await act(async () => {});
expect(fetch).toHaveBeenCalledTimes(2);
expect(JSON.parse(localStorage.getItem(GITHUB_STAR_CACHE_KEY) ?? "{}")).toEqual({ count: 100, fetchedAt: NOW - GITHUB_STAR_CACHE_TTL_MS - 1 });
vi.mocked(fetch).mockResolvedValueOnce(response(200));
document.dispatchEvent(new Event("visibilitychange"));
await act(async () => {});
expect(result.current).toBe(200);
});
it("cleans up its timer and visibility listener on unmount", async () => {
writeCache(100);
const { unmount } = renderHook(() => useGitHubStarCount());
unmount();
vi.setSystemTime(NOW + GITHUB_STAR_CACHE_TTL_MS + 1);
await act(async () => { await vi.advanceTimersByTimeAsync(GITHUB_STAR_CACHE_TTL_MS * 2); });
document.dispatchEvent(new Event("visibilitychange"));
expect(fetch).not.toHaveBeenCalled();
});
});