From 96606b766c6266f6b245fc5a3713c248d1a1e6cd Mon Sep 17 00:00:00 2001 From: testvalue Date: Thu, 2 Apr 2026 16:27:18 -0400 Subject: [PATCH 1/6] feat(poll): enables background refresh when tab is hidden Removes visibility guards that skipped API fetches when the browser tab was not visible. Both the full poll (5-min interval) and hot poll (30s interval) now continue fetching in the background. The existing visibilitychange catch-up handler is retained as a safety net for Safari tab purge and Chrome Energy Saver freeze scenarios. --- src/app/services/poll.ts | 7 ------- tests/services/hot-poll.test.ts | 4 ++-- tests/services/poll.test.ts | 6 +++--- 3 files changed, 5 insertions(+), 12 deletions(-) diff --git a/src/app/services/poll.ts b/src/app/services/poll.ts index 79926454..e80b5c65 100644 --- a/src/app/services/poll.ts +++ b/src/app/services/poll.ts @@ -392,7 +392,6 @@ export function createPollCoordinator( const intervalMs = withJitter(intervalSec * 1000); intervalId = setInterval(() => { - if (document.visibilityState === "hidden") return; void doFetch(); }, intervalMs); } @@ -621,12 +620,6 @@ export function createHotPollCoordinator( return; } - // Skip fetch when page is hidden - if (document.visibilityState === "hidden") { - schedule(myGeneration); - return; - } - // Skip fetch when no authenticated client (e.g., mid-logout) // Guarded: getClient() can throw during auth state transitions let client: ReturnType; diff --git a/tests/services/hot-poll.test.ts b/tests/services/hot-poll.test.ts index 36cfabeb..261b523c 100644 --- a/tests/services/hot-poll.test.ts +++ b/tests/services/hot-poll.test.ts @@ -517,7 +517,7 @@ describe("createHotPollCoordinator", () => { }); }); - it("skips fetch when document is hidden", async () => { + it("continues fetching when document is hidden", async () => { const onHotData = vi.fn(); mockGetClient.mockReturnValue(makeOctokit()); @@ -530,7 +530,7 @@ describe("createHotPollCoordinator", () => { createHotPollCoordinator(() => 10, onHotData); Object.defineProperty(document, "visibilityState", { value: "hidden", writable: true, configurable: true }); await vi.advanceTimersByTimeAsync(10_000); - expect(onHotData).not.toHaveBeenCalled(); + expect(onHotData).toHaveBeenCalled(); Object.defineProperty(document, "visibilityState", { value: "visible", writable: true, configurable: true }); dispose(); }); diff --git a/tests/services/poll.test.ts b/tests/services/poll.test.ts index 0a81f1e5..b38a2e72 100644 --- a/tests/services/poll.test.ts +++ b/tests/services/poll.test.ts @@ -119,7 +119,7 @@ describe("createPollCoordinator", () => { }); }); - it("pauses polling when document is hidden", async () => { + it("continues polling when document is hidden", async () => { const fetchAll = makeFetchAll(); await createRoot(async (dispose) => { @@ -135,8 +135,8 @@ describe("createPollCoordinator", () => { vi.advanceTimersByTime(90_000); await Promise.resolve(); - // Should not have fetched while hidden - expect(fetchAll.mock.calls.length).toBe(callsAfterInit); + // Should have fetched while hidden (background refresh) + expect(fetchAll.mock.calls.length).toBeGreaterThan(callsAfterInit); dispose(); }); }); From 0c81bbe8b71e5829af96db0892106f0a7ae5b593 Mon Sep 17 00:00:00 2001 From: testvalue Date: Thu, 2 Apr 2026 19:28:41 -0400 Subject: [PATCH 2/6] fix(poll): keeps hot poll paused when hidden, updates docs and tests --- src/app/services/poll.ts | 19 ++++++++++-- tests/services/hot-poll.test.ts | 5 ++-- tests/services/poll.test.ts | 51 +++++++++++++++++++++++++++++++-- 3 files changed, 68 insertions(+), 7 deletions(-) diff --git a/src/app/services/poll.ts b/src/app/services/poll.ts index e80b5c65..108c4d7d 100644 --- a/src/app/services/poll.ts +++ b/src/app/services/poll.ts @@ -323,8 +323,9 @@ function withJitter(intervalMs: number): number { * - Triggers an immediate fetch on init * - Polls at getInterval() seconds (reactive — restarts when interval changes) * - If getInterval() === 0, disables auto-polling (SDR-017) - * - Pauses when document is hidden; resumes on visibility restore - * - Refreshes immediately on re-visible if hidden for >2 min + * - Continues polling in background tabs (no visibility pause) + * - On re-visible after >2 min hidden, fires catch-up fetch (safety net for + * browser tab throttling/freezing — Safari purge, Chrome Energy Saver) * - Applies ±30 second jitter to poll interval * * Must be called inside a reactive root (e.g., createRoot or component body). @@ -396,6 +397,12 @@ export function createPollCoordinator( }, intervalMs); } + // Safety net for browser-level tab throttling/freezing. Background polling + // continues via setInterval, but browsers may throttle or freeze timers in + // hidden tabs (Chrome Energy Saver, Safari tab purge, Firefox timer capping). + // When the tab becomes visible again after >2 min, this handler fires a + // catch-up fetch in case the browser suppressed scheduled polls. The + // notifications gate (304) makes redundant fetches near-zero cost. function handleVisibilityChange(): void { if (document.visibilityState === "hidden") { hiddenAt = Date.now(); @@ -620,6 +627,14 @@ export function createHotPollCoordinator( return; } + // Skip fetch when page is hidden — hot poll provides visual feedback + // (shimmer, status changes) that has no value in a background tab. + // The full poll continues in background for data freshness. + if (document.visibilityState === "hidden") { + schedule(myGeneration); + return; + } + // Skip fetch when no authenticated client (e.g., mid-logout) // Guarded: getClient() can throw during auth state transitions let client: ReturnType; diff --git a/tests/services/hot-poll.test.ts b/tests/services/hot-poll.test.ts index 261b523c..549574e2 100644 --- a/tests/services/hot-poll.test.ts +++ b/tests/services/hot-poll.test.ts @@ -517,7 +517,7 @@ describe("createHotPollCoordinator", () => { }); }); - it("continues fetching when document is hidden", async () => { + it("skips fetch when document is hidden", async () => { const onHotData = vi.fn(); mockGetClient.mockReturnValue(makeOctokit()); @@ -530,8 +530,7 @@ describe("createHotPollCoordinator", () => { createHotPollCoordinator(() => 10, onHotData); Object.defineProperty(document, "visibilityState", { value: "hidden", writable: true, configurable: true }); await vi.advanceTimersByTimeAsync(10_000); - expect(onHotData).toHaveBeenCalled(); - Object.defineProperty(document, "visibilityState", { value: "visible", writable: true, configurable: true }); + expect(onHotData).not.toHaveBeenCalled(); dispose(); }); }); diff --git a/tests/services/poll.test.ts b/tests/services/poll.test.ts index b38a2e72..2f7fd96f 100644 --- a/tests/services/poll.test.ts +++ b/tests/services/poll.test.ts @@ -160,13 +160,16 @@ describe("createPollCoordinator", () => { setDocumentVisible(true); await Promise.resolve(); - // Should have triggered an immediate fetch on re-visible - expect(fetchAll.mock.calls.length).toBe(callsAfterInit + 1); + // Should have triggered at least a catch-up fetch on re-visible + // (background polls may also have fired if interval < hidden duration) + expect(fetchAll.mock.calls.length).toBeGreaterThanOrEqual(callsAfterInit + 1); dispose(); }); }); it("does NOT trigger immediate refresh on re-visible within 2 minutes", async () => { + // Pin jitter to 0 so 300s interval is exactly 300s (no background poll in 90s) + const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.5); const fetchAll = makeFetchAll(); await createRoot(async (dispose) => { @@ -187,6 +190,50 @@ describe("createPollCoordinator", () => { expect(fetchAll.mock.calls.length).toBe(callsAfterInit); dispose(); }); + + randomSpy.mockRestore(); + }); + + it("resets timer on re-visible after >2 min, preventing double-fire with background polls", async () => { + // Pin jitter to 0 so 60s interval is exactly 60s + const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.5); + const fetchAll = makeFetchAll(); + + await createRoot(async (dispose) => { + createPollCoordinator(makeGetInterval(60), fetchAll); + await Promise.resolve(); // initial fetch + + const callsAfterInit = fetchAll.mock.calls.length; + + // Hide for >2 min — background polls fire at 60s and 120s + setDocumentVisible(false); + vi.advanceTimersByTime(130_000); + await Promise.resolve(); + + const callsWhileHidden = fetchAll.mock.calls.length; + expect(callsWhileHidden).toBeGreaterThan(callsAfterInit); + + // Restore visibility — catch-up fetch fires + timer resets + setDocumentVisible(true); + await Promise.resolve(); + + const callsAfterRevisible = fetchAll.mock.calls.length; + expect(callsAfterRevisible).toBeGreaterThan(callsWhileHidden); + + // Advance 30s — should NOT fire (timer was reset to full 60s interval) + vi.advanceTimersByTime(30_000); + await Promise.resolve(); + expect(fetchAll.mock.calls.length).toBe(callsAfterRevisible); + + // Advance another 31s (61s from reset) — timer fires + vi.advanceTimersByTime(31_000); + await Promise.resolve(); + expect(fetchAll.mock.calls.length).toBeGreaterThan(callsAfterRevisible); + + dispose(); + }); + + randomSpy.mockRestore(); }); it("manual refresh triggers fetch and resets the timer", async () => { From 292e0243f6d7b61c1692859bb3056880ae7bfc5d Mon Sep 17 00:00:00 2001 From: testvalue Date: Thu, 2 Apr 2026 19:44:45 -0400 Subject: [PATCH 3/6] docs(poll): updates README and JSDoc for background refresh --- README.md | 4 ++-- src/app/services/poll.ts | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7f9fa6ca..d82ba6a2 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Dashboard SPA tracking GitHub issues, PRs, and GHA workflow runs across multiple - **Ignore System** — Hide specific items with an "N ignored" badge and unignore popover. - **Dark Mode** — System-aware with flash prevention via inline script + CSP SHA-256 hash. - **ETag Caching** — Conditional requests (304s are free against GitHub's rate limit). -- **Auto-refresh** — Visibility-aware polling that pauses when tab is hidden. +- **Auto-refresh** — Background polling keeps data fresh even in hidden tabs; hot poll pauses to save API budget. ## Tech Stack @@ -55,7 +55,7 @@ src/ services/ api.ts # GitHub API methods (fetchOrgs, fetchRepos, fetchIssues, fetchPRs, fetchWorkflowRuns) github.ts # Octokit client factory with ETag caching and rate limit tracking - poll.ts # Poll coordinator with visibility-aware auto-refresh + poll.ts # Poll coordinator with background refresh + hot poll for in-flight items stores/ auth.ts # OAuth token management (localStorage persistence, validateToken) cache.ts # IndexedDB cache with TTL eviction and ETag support diff --git a/src/app/services/poll.ts b/src/app/services/poll.ts index 108c4d7d..90fbb76e 100644 --- a/src/app/services/poll.ts +++ b/src/app/services/poll.ts @@ -580,6 +580,9 @@ export async function fetchHotData(): Promise<{ * in-flight items without a full poll cycle. Uses setTimeout chains to avoid * overlapping concurrent fetches. * + * - Pauses when document is hidden (visual-only feedback has no value in background tabs) + * - Resumes on next scheduled cycle when tab becomes visible + * * Must be called inside a SolidJS reactive root (uses createEffect + onCleanup). * * @param getInterval - Reactive accessor returning interval in seconds From 206d715f53a0384a9f48cff52960f9c84a9e0b3d Mon Sep 17 00:00:00 2001 From: testvalue Date: Fri, 3 Apr 2026 09:00:31 -0400 Subject: [PATCH 4/6] feat(poll): skips background polls when notifications gate is disabled --- README.md | 2 +- src/app/services/poll.ts | 19 ++++++++++++++++--- tests/services/poll.test.ts | 30 ++++++++++++++++++++++++++++-- 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index d82ba6a2..8bf61dac 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Dashboard SPA tracking GitHub issues, PRs, and GHA workflow runs across multiple - **Ignore System** — Hide specific items with an "N ignored" badge and unignore popover. - **Dark Mode** — System-aware with flash prevention via inline script + CSP SHA-256 hash. - **ETag Caching** — Conditional requests (304s are free against GitHub's rate limit). -- **Auto-refresh** — Background polling keeps data fresh even in hidden tabs; hot poll pauses to save API budget. +- **Auto-refresh** — Background polling keeps data fresh even in hidden tabs (requires notifications scope for efficient 304 change detection); hot poll pauses to save API budget. ## Tech Stack diff --git a/src/app/services/poll.ts b/src/app/services/poll.ts index 90fbb76e..0952ba68 100644 --- a/src/app/services/poll.ts +++ b/src/app/services/poll.ts @@ -71,6 +71,12 @@ export function clearHotSets(): void { _hotRuns.clear(); } +/** Simulate 403 on /notifications — disables the notifications gate. + * Used by tests to exercise the conditional background-poll guard. */ +export function disableNotifGate(): void { + _notifGateDisabled = true; +} + export function resetPollState(): void { _notifLastModified = null; _lastSuccessfulFetch = null; @@ -178,8 +184,8 @@ async function hasNotificationChanges(): Promise { ) { console.warn("[poll] Notifications API returned 403 — disabling gate"); pushNotification("notifications", config.authMethod === "pat" - ? "Notifications API returned 403 — fine-grained tokens do not support notifications; classic tokens need the notifications scope" - : "Notifications API returned 403 — check that the notifications scope is granted", "warning"); + ? "Notifications API returned 403 — fine-grained tokens do not support notifications; classic tokens need the notifications scope. Background refresh in hidden tabs is disabled." + : "Notifications API returned 403 — check that the notifications scope is granted. Background refresh in hidden tabs is disabled.", "warning"); _notifGateDisabled = true; } return true; @@ -323,7 +329,10 @@ function withJitter(intervalMs: number): number { * - Triggers an immediate fetch on init * - Polls at getInterval() seconds (reactive — restarts when interval changes) * - If getInterval() === 0, disables auto-polling (SDR-017) - * - Continues polling in background tabs (no visibility pause) + * - Continues polling in background tabs when notifications gate is available + * (304 responses make background polls near-zero cost). When the gate is + * disabled (fine-grained PAT or missing notifications scope), background + * polling pauses to conserve API budget. * - On re-visible after >2 min hidden, fires catch-up fetch (safety net for * browser tab throttling/freezing — Safari purge, Chrome Energy Saver) * - Applies ±30 second jitter to poll interval @@ -393,6 +402,10 @@ export function createPollCoordinator( const intervalMs = withJitter(intervalSec * 1000); intervalId = setInterval(() => { + // Without the notifications gate (403 — scope not granted), every background + // poll is a full fetch with no 304 shortcut. Skip background polls to avoid + // burning API budget; the catch-up handler still fires on tab return. + if (document.visibilityState === "hidden" && _notifGateDisabled) return; void doFetch(); }, intervalMs); } diff --git a/tests/services/poll.test.ts b/tests/services/poll.test.ts index 2f7fd96f..f7d1dd7a 100644 --- a/tests/services/poll.test.ts +++ b/tests/services/poll.test.ts @@ -1,7 +1,7 @@ import "fake-indexeddb/auto"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { createRoot } from "solid-js"; -import { createPollCoordinator, type DashboardData } from "../../src/app/services/poll"; +import { createPollCoordinator, disableNotifGate, resetPollState, type DashboardData } from "../../src/app/services/poll"; // Mock pushError so we can spy on it const mockPushError = vi.fn(); @@ -29,6 +29,7 @@ vi.mock("../../src/app/lib/errors", () => ({ vi.mock("../../src/app/lib/notifications", () => ({ detectNewItems: vi.fn(() => []), dispatchNotifications: vi.fn(), + _resetNotificationState: vi.fn(), })); // Mock config so doFetch doesn't fail when accessing config.selectedRepos @@ -119,7 +120,7 @@ describe("createPollCoordinator", () => { }); }); - it("continues polling when document is hidden", async () => { + it("continues polling when document is hidden (notifications gate enabled)", async () => { const fetchAll = makeFetchAll(); await createRoot(async (dispose) => { @@ -167,6 +168,31 @@ describe("createPollCoordinator", () => { }); }); + it("pauses background polling when hidden and notifications gate is disabled", async () => { + disableNotifGate(); + const fetchAll = makeFetchAll(); + + await createRoot(async (dispose) => { + createPollCoordinator(makeGetInterval(60), fetchAll); + await Promise.resolve(); // initial fetch + + const callsAfterInit = fetchAll.mock.calls.length; + + // Hide document + setDocumentVisible(false); + + // Advance past the interval + vi.advanceTimersByTime(90_000); + await Promise.resolve(); + + // Should NOT have fetched — gate disabled means no cheap 304, skip background polls + expect(fetchAll.mock.calls.length).toBe(callsAfterInit); + dispose(); + }); + + resetPollState(); // restore gate for other tests + }); + it("does NOT trigger immediate refresh on re-visible within 2 minutes", async () => { // Pin jitter to 0 so 300s interval is exactly 300s (no background poll in 90s) const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.5); From c0bc23c4fce4c417871672745cf319da8c65f2f8 Mon Sep 17 00:00:00 2001 From: testvalue Date: Fri, 3 Apr 2026 09:07:38 -0400 Subject: [PATCH 5/6] test(poll): adds hot poll resume test and fixes config change test --- tests/services/hot-poll.test.ts | 30 +++++++++++++++++++++++ tests/services/poll.test.ts | 43 +++++++++++++++------------------ 2 files changed, 49 insertions(+), 24 deletions(-) diff --git a/tests/services/hot-poll.test.ts b/tests/services/hot-poll.test.ts index 549574e2..c2f2b347 100644 --- a/tests/services/hot-poll.test.ts +++ b/tests/services/hot-poll.test.ts @@ -535,6 +535,36 @@ describe("createHotPollCoordinator", () => { }); }); + it("resumes fetching after hidden→visible transition", async () => { + const onHotData = vi.fn(); + const requestFn = vi.fn(() => Promise.resolve({ + data: { id: 1, status: "in_progress", conclusion: null, updated_at: "2026-01-01T00:00:00Z", completed_at: null }, + headers: {}, + })); + mockGetClient.mockReturnValue(makeOctokit(requestFn)); + + rebuildHotSets({ + ...emptyData, + workflowRuns: [makeWorkflowRun({ id: 1, status: "in_progress", conclusion: null, repoFullName: "o/r" })], + }); + + await createRoot(async (dispose) => { + createHotPollCoordinator(() => 10, onHotData); + + // Hidden — cycle skips fetch + Object.defineProperty(document, "visibilityState", { value: "hidden", writable: true, configurable: true }); + await vi.advanceTimersByTimeAsync(10_000); + expect(onHotData).not.toHaveBeenCalled(); + + // Visible — next cycle should fetch + Object.defineProperty(document, "visibilityState", { value: "visible", writable: true, configurable: true }); + await vi.advanceTimersByTimeAsync(10_000); + expect(onHotData).toHaveBeenCalled(); + + dispose(); + }); + }); + it("resets backoff counter on successful cycle", async () => { const onHotData = vi.fn(); const requestFn = vi.fn(() => Promise.resolve({ diff --git a/tests/services/poll.test.ts b/tests/services/poll.test.ts index f7d1dd7a..8d579893 100644 --- a/tests/services/poll.test.ts +++ b/tests/services/poll.test.ts @@ -1,6 +1,6 @@ import "fake-indexeddb/auto"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { createRoot } from "solid-js"; +import { createRoot, createSignal } from "solid-js"; import { createPollCoordinator, disableNotifGate, resetPollState, type DashboardData } from "../../src/app/services/poll"; // Mock pushError so we can spy on it @@ -280,40 +280,35 @@ describe("createPollCoordinator", () => { }); it("config change (interval change) restarts the interval", async () => { + const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.5); // jitter = 0 const fetchAll = makeFetchAll(); - let intervalSec = 300; await createRoot(async (dispose) => { - // Use a signal-based getter to simulate reactive config - const [getInterval, setGetInterval] = (() => { - let fn = () => intervalSec; - return [ - () => fn(), - (newFn: () => number) => { - fn = newFn; - }, - ] as const; - })(); - - createPollCoordinator(getInterval, fetchAll); + const [interval, setInterval] = createSignal(300); + + createPollCoordinator(interval, fetchAll); await Promise.resolve(); // initial fetch - // Simulate config change to shorter interval by providing a new accessor - // In practice SolidJS createEffect re-runs when reactive dependencies change. - // Here we verify that calling with interval=60 fires within 90s. - intervalSec = 60; - void setGetInterval; // suppress unused warning + const callsAfterInit = fetchAll.mock.calls.length; + // At 300s interval, 90s should NOT fire vi.advanceTimersByTime(90_000); await Promise.resolve(); + expect(fetchAll.mock.calls.length).toBe(callsAfterInit); + + // Change interval to 60s — createEffect re-fires, timer restarts + setInterval(60); + await Promise.resolve(); // let effect run + + // Advance 61s — new 60s interval should fire + vi.advanceTimersByTime(61_000); + await Promise.resolve(); + expect(fetchAll.mock.calls.length).toBeGreaterThan(callsAfterInit); - // At 300s interval, 90s would not fire. But with 60s interval restart, - // it should fire at least once more. Since the internal createEffect - // is not re-triggered (intervalSec is not a signal), we only verify - // that the original timer was set and would eventually fire. - // The key test is just that manualRefresh + timer work correctly. dispose(); }); + + randomSpy.mockRestore(); }); it("interval=0 disables auto-refresh (no setInterval)", async () => { From af4ff1785ba1004c13974a294099fec1b5f4c301 Mon Sep 17 00:00:00 2001 From: testvalue Date: Fri, 3 Apr 2026 09:12:54 -0400 Subject: [PATCH 6/6] test(poll): pins Math.random in background poll test, fixes qa-3 tag --- tests/services/poll.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/services/poll.test.ts b/tests/services/poll.test.ts index 8d579893..87080234 100644 --- a/tests/services/poll.test.ts +++ b/tests/services/poll.test.ts @@ -121,6 +121,7 @@ describe("createPollCoordinator", () => { }); it("continues polling when document is hidden (notifications gate enabled)", async () => { + const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.5); // jitter = 0 const fetchAll = makeFetchAll(); await createRoot(async (dispose) => { @@ -132,14 +133,16 @@ describe("createPollCoordinator", () => { // Hide document setDocumentVisible(false); - // Advance past the interval - vi.advanceTimersByTime(90_000); + // Advance past the interval (60s with 0 jitter) + vi.advanceTimersByTime(61_000); await Promise.resolve(); // Should have fetched while hidden (background refresh) expect(fetchAll.mock.calls.length).toBeGreaterThan(callsAfterInit); dispose(); }); + + randomSpy.mockRestore(); }); it("triggers immediate refresh on re-visible after >2 minutes hidden", async () => { @@ -474,7 +477,7 @@ describe("createPollCoordinator", () => { }); }); - // ── qa-3: doFetch skipped path — no restore (reconciliation replaces snapshot/restore) ── + // ── qa-3a: doFetch skipped path — no restore (reconciliation replaces snapshot/restore) ── it("skipped fetch does NOT call pushError for previous errors (no restore logic)", async () => { mockPushError.mockClear();