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";
// ---------------------------------------------------------------------------
// 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";
const GITHUB_STAR_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
export const GITHUB_STAR_CACHE_KEY = "fusion_github_star_count";
export const GITHUB_STAR_CACHE_TTL_MS = 15 * 60 * 1000;
const GITHUB_STAR_CLICKED_KEY = "fusion:github-star-clicked";
function isSlashPrefixedAbsolutePath(path: string): boolean {
@@ -156,57 +156,80 @@ interface StarCache {
fetchedAt: number;
}
function useGitHubStarCount(): number | null {
const [count, setCount] = useState<number | null>(() => {
try {
const raw = localStorage.getItem(GITHUB_STAR_CACHE_KEY);
if (raw) {
const parsed: StarCache = JSON.parse(raw) as StarCache;
if (Date.now() - parsed.fetchedAt < GITHUB_STAR_CACHE_TTL_MS) {
return parsed.count;
}
}
} catch {
// ignore malformed cache
}
function readGitHubStarCache(): StarCache | null {
try {
const raw = localStorage.getItem(GITHUB_STAR_CACHE_KEY);
if (!raw) return null;
const parsed: StarCache = JSON.parse(raw) as StarCache;
return Number.isFinite(parsed.count) && Number.isFinite(parsed.fetchedAt) ? parsed : null;
} catch {
return null;
});
}
}
useEffect(() => {
// If we already have a fresh count from the initial state, skip the fetch.
try {
const raw = localStorage.getItem(GITHUB_STAR_CACHE_KEY);
if (raw) {
const parsed: StarCache = JSON.parse(raw) as StarCache;
if (Date.now() - parsed.fetchedAt < GITHUB_STAR_CACHE_TTL_MS) {
return;
}
}
} catch {
// ignore
function isGitHubStarCacheFresh(cache: StarCache | null): boolean {
return cache !== null && Date.now() - cache.fetchedAt < GITHUB_STAR_CACHE_TTL_MS;
}
/**
* FNXC:SettingsGitHubStar 2026-07-16-07:47:
* Keep the Settings star counter current with a visibility-gated, bounded interval refresh.
* 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.
*/
export function useGitHubStarCount(): number | null {
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")
.then((res) => {
if (!res.ok) return;
return res.json() as Promise<{ stargazers_count?: number }>;
inFlightRef.current = true;
void fetch("https://api.github.com/repos/Runfusion/Fusion", { cache: "no-store" })
.then(async (response) => {
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) => {
if (data && typeof data.stargazers_count === "number") {
const cache: StarCache = { count: data.stargazers_count, fetchedAt: Date.now() };
try {
localStorage.setItem(GITHUB_STAR_CACHE_KEY, JSON.stringify(cache));
} catch {
// quota exceeded — just skip
}
setCount(data.stargazers_count);
.then((starCount) => {
if (starCount === null) return;
const cache: StarCache = { count: starCount, fetchedAt: Date.now() };
try {
localStorage.setItem(GITHUB_STAR_CACHE_KEY, JSON.stringify(cache));
} catch {
// quota exceeded — preserve the displayed value even when persistence is unavailable
}
setCount(starCount);
})
.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;
}

View File

@@ -1,7 +1,7 @@
import fs from "node:fs";
import { loadAllAppCss } from "../../test/cssFixture";
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 userEvent from "@testing-library/user-event";
import { SettingsModal, SettingsView } from "../SettingsModal";
@@ -153,6 +153,11 @@ vi.mock("../../hooks/useMemoryBackendStatus", () => ({
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 {
Object.defineProperty(window, "matchMedia", {
writable: true,
@@ -221,12 +226,17 @@ function expectBaseRule(css: string, selector: string, declaration: string): voi
describe("SettingsModal mobile adaptations", () => {
beforeEach(() => {
vi.clearAllMocks();
setDocumentHidden(false);
localStorage.removeItem("fusion_github_star_count");
localStorage.removeItem("fusion:github-star-clicked");
localStorage.setItem("fusion:settings:show-advanced", "true");
mockSettingsViewport(false);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("renders mobile-targeted settings layout classes", async () => {
const { container } = render(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
@@ -660,6 +670,53 @@ describe("SettingsModal mobile adaptations", () => {
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", () => {
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();
});
});