diff --git a/.changeset/fn-8099-github-star-refresh.md b/.changeset/fn-8099-github-star-refresh.md new file mode 100644 index 0000000000..e41c5d58bd --- /dev/null +++ b/.changeset/fn-8099-github-star-refresh.md @@ -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. diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index a6eddb8c40..ea40fd1f75 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -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(() => { - 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(() => 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; } diff --git a/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx b/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx index b0ca3c60bd..0f43ee1253 100644 --- a/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx @@ -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(); 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 }) => ], + ["embedded", (props: { onClose: () => void; addToast: () => void }) => ], + ])("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((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(); + 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 }) => ], + ["embedded", (props: { onClose: () => void; addToast: () => void }) => ], + ])("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(); + 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(); diff --git a/packages/dashboard/app/components/__tests__/useGitHubStarCount.test.tsx b/packages/dashboard/app/components/__tests__/useGitHubStarCount.test.tsx new file mode 100644 index 0000000000..55f7f6bfd7 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/useGitHubStarCount.test.tsx @@ -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(); + }); +});