Skip to content

Commit 206d715

Browse files
committed
feat(poll): skips background polls when notifications gate is disabled
1 parent 292e024 commit 206d715

3 files changed

Lines changed: 45 additions & 6 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ Dashboard SPA tracking GitHub issues, PRs, and GHA workflow runs across multiple
1818
- **Ignore System** — Hide specific items with an "N ignored" badge and unignore popover.
1919
- **Dark Mode** — System-aware with flash prevention via inline script + CSP SHA-256 hash.
2020
- **ETag Caching** — Conditional requests (304s are free against GitHub's rate limit).
21-
- **Auto-refresh** — Background polling keeps data fresh even in hidden tabs; hot poll pauses to save API budget.
21+
- **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.
2222

2323
## Tech Stack
2424

src/app/services/poll.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,12 @@ export function clearHotSets(): void {
7171
_hotRuns.clear();
7272
}
7373

74+
/** Simulate 403 on /notifications — disables the notifications gate.
75+
* Used by tests to exercise the conditional background-poll guard. */
76+
export function disableNotifGate(): void {
77+
_notifGateDisabled = true;
78+
}
79+
7480
export function resetPollState(): void {
7581
_notifLastModified = null;
7682
_lastSuccessfulFetch = null;
@@ -178,8 +184,8 @@ async function hasNotificationChanges(): Promise<boolean> {
178184
) {
179185
console.warn("[poll] Notifications API returned 403 — disabling gate");
180186
pushNotification("notifications", config.authMethod === "pat"
181-
? "Notifications API returned 403 — fine-grained tokens do not support notifications; classic tokens need the notifications scope"
182-
: "Notifications API returned 403 — check that the notifications scope is granted", "warning");
187+
? "Notifications API returned 403 — fine-grained tokens do not support notifications; classic tokens need the notifications scope. Background refresh in hidden tabs is disabled."
188+
: "Notifications API returned 403 — check that the notifications scope is granted. Background refresh in hidden tabs is disabled.", "warning");
183189
_notifGateDisabled = true;
184190
}
185191
return true;
@@ -323,7 +329,10 @@ function withJitter(intervalMs: number): number {
323329
* - Triggers an immediate fetch on init
324330
* - Polls at getInterval() seconds (reactive — restarts when interval changes)
325331
* - If getInterval() === 0, disables auto-polling (SDR-017)
326-
* - Continues polling in background tabs (no visibility pause)
332+
* - Continues polling in background tabs when notifications gate is available
333+
* (304 responses make background polls near-zero cost). When the gate is
334+
* disabled (fine-grained PAT or missing notifications scope), background
335+
* polling pauses to conserve API budget.
327336
* - On re-visible after >2 min hidden, fires catch-up fetch (safety net for
328337
* browser tab throttling/freezing — Safari purge, Chrome Energy Saver)
329338
* - Applies ±30 second jitter to poll interval
@@ -393,6 +402,10 @@ export function createPollCoordinator(
393402

394403
const intervalMs = withJitter(intervalSec * 1000);
395404
intervalId = setInterval(() => {
405+
// Without the notifications gate (403 — scope not granted), every background
406+
// poll is a full fetch with no 304 shortcut. Skip background polls to avoid
407+
// burning API budget; the catch-up handler still fires on tab return.
408+
if (document.visibilityState === "hidden" && _notifGateDisabled) return;
396409
void doFetch();
397410
}, intervalMs);
398411
}

tests/services/poll.test.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import "fake-indexeddb/auto";
22
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
33
import { createRoot } from "solid-js";
4-
import { createPollCoordinator, type DashboardData } from "../../src/app/services/poll";
4+
import { createPollCoordinator, disableNotifGate, resetPollState, type DashboardData } from "../../src/app/services/poll";
55

66
// Mock pushError so we can spy on it
77
const mockPushError = vi.fn();
@@ -29,6 +29,7 @@ vi.mock("../../src/app/lib/errors", () => ({
2929
vi.mock("../../src/app/lib/notifications", () => ({
3030
detectNewItems: vi.fn(() => []),
3131
dispatchNotifications: vi.fn(),
32+
_resetNotificationState: vi.fn(),
3233
}));
3334

3435
// Mock config so doFetch doesn't fail when accessing config.selectedRepos
@@ -119,7 +120,7 @@ describe("createPollCoordinator", () => {
119120
});
120121
});
121122

122-
it("continues polling when document is hidden", async () => {
123+
it("continues polling when document is hidden (notifications gate enabled)", async () => {
123124
const fetchAll = makeFetchAll();
124125

125126
await createRoot(async (dispose) => {
@@ -167,6 +168,31 @@ describe("createPollCoordinator", () => {
167168
});
168169
});
169170

171+
it("pauses background polling when hidden and notifications gate is disabled", async () => {
172+
disableNotifGate();
173+
const fetchAll = makeFetchAll();
174+
175+
await createRoot(async (dispose) => {
176+
createPollCoordinator(makeGetInterval(60), fetchAll);
177+
await Promise.resolve(); // initial fetch
178+
179+
const callsAfterInit = fetchAll.mock.calls.length;
180+
181+
// Hide document
182+
setDocumentVisible(false);
183+
184+
// Advance past the interval
185+
vi.advanceTimersByTime(90_000);
186+
await Promise.resolve();
187+
188+
// Should NOT have fetched — gate disabled means no cheap 304, skip background polls
189+
expect(fetchAll.mock.calls.length).toBe(callsAfterInit);
190+
dispose();
191+
});
192+
193+
resetPollState(); // restore gate for other tests
194+
});
195+
170196
it("does NOT trigger immediate refresh on re-visible within 2 minutes", async () => {
171197
// Pin jitter to 0 so 300s interval is exactly 300s (no background poll in 90s)
172198
const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.5);

0 commit comments

Comments
 (0)