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:
7
.changeset/fn-8099-github-star-refresh.md
Normal file
7
.changeset/fn-8099-github-star-refresh.md
Normal 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.
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user