Skip to content

Commit 6b75091

Browse files
committed
fix(hot-poll): addresses all PR review findings
1 parent a886ebe commit 6b75091

File tree

7 files changed

+294
-42
lines changed

7 files changed

+294
-42
lines changed

src/app/components/dashboard/DashboardPage.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ import { getClient, getGraphqlRateLimit } from "../../services/github";
2424

2525
// ── Shared dashboard store (module-level to survive navigation) ─────────────
2626

27+
// Bump only for breaking schema changes (renames, type changes). Additive optional
28+
// fields (e.g., nodeId?: string) don't require a bump — missing fields deserialize
29+
// as undefined, which consuming code handles gracefully.
2730
const CACHE_VERSION = 2;
2831

2932
interface DashboardStore {
@@ -240,7 +243,6 @@ export default function DashboardPage() {
240243
_setHotCoordinator(createHotPollCoordinator(
241244
() => config.hotPollInterval,
242245
(prUpdates, runUpdates, fetchGeneration) => {
243-
if (prUpdates.size === 0 && runUpdates.size === 0) return;
244246
// Guard against stale hot poll results overlapping with a full refresh.
245247
// fetchGeneration was captured BEFORE fetchHotData() started its async work.
246248
// If a full refresh completed during the fetch, _hotPollGeneration will have

src/app/services/api.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1834,12 +1834,12 @@ export async function fetchWorkflowRunById(
18341834
run_id: id,
18351835
});
18361836
updateRateLimitFromHeaders(response.headers as Record<string, string>);
1837-
const run = response.data;
1837+
const run = response.data as unknown as RawWorkflowRun;
18381838
return {
18391839
id: run.id,
18401840
status: run.status ?? "",
18411841
conclusion: run.conclusion ?? null,
18421842
updatedAt: run.updated_at,
1843-
completedAt: ("completed_at" in run ? (run as Record<string, unknown>).completed_at as string | null : null) ?? null,
1843+
completedAt: run.completed_at ?? null,
18441844
};
18451845
}

src/app/services/poll.ts

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,14 @@ let _notifGateDisabled = false; // Disabled after 403 (notifications scope not g
4646

4747
/** PRs with pending/null check status: maps GraphQL node ID → databaseId */
4848
const _hotPRs = new Map<string, number>();
49+
/** Inverse index for O(1) eviction: maps databaseId → nodeId */
50+
const _hotPRsByDbId = new Map<number, string>();
4951
const MAX_HOT_PRS = 200;
5052

5153
/** In-progress workflow runs: maps run ID → repo descriptor */
5254
const _hotRuns = new Map<number, { owner: string; repo: string }>();
5355
const MAX_HOT_RUNS = 30;
56+
const HOT_RUNS_CONCURRENCY = 10;
5457

5558
/** Incremented each time rebuildHotSets() is called (full refresh completed).
5659
* Allows hot poll callbacks to detect stale results that overlap with a fresh
@@ -64,6 +67,7 @@ export function getHotPollGeneration(): number {
6467

6568
export function clearHotSets(): void {
6669
_hotPRs.clear();
70+
_hotPRsByDbId.clear();
6771
_hotRuns.clear();
6872
}
6973

@@ -72,6 +76,7 @@ export function resetPollState(): void {
7276
_lastSuccessfulFetch = null;
7377
_notifGateDisabled = false;
7478
_hotPRs.clear();
79+
_hotPRsByDbId.clear();
7580
_hotRuns.clear();
7681
_hotPollGeneration = 0;
7782
_resetNotificationState();
@@ -391,6 +396,7 @@ export function createPollCoordinator(
391396
export function rebuildHotSets(data: DashboardData): void {
392397
_hotPollGeneration++;
393398
_hotPRs.clear();
399+
_hotPRsByDbId.clear();
394400
_hotRuns.clear();
395401

396402
for (const pr of data.pullRequests) {
@@ -400,6 +406,7 @@ export function rebuildHotSets(data: DashboardData): void {
400406
break;
401407
}
402408
_hotPRs.set(pr.nodeId, pr.id);
409+
_hotPRsByDbId.set(pr.id, pr.nodeId);
403410
}
404411
}
405412

@@ -459,9 +466,9 @@ export async function fetchHotData(): Promise<{
459466
// Workflow run fetches — bounded concurrency via pooledAllSettled
460467
const runEntries = [..._hotRuns.entries()];
461468
const runTasks = runEntries.map(
462-
(entry) => async () => fetchWorkflowRunById(octokit, { id: entry[0], ...entry[1] })
469+
([runId, descriptor]) => async () => fetchWorkflowRunById(octokit, { id: runId, ...descriptor })
463470
);
464-
const runResults = await pooledAllSettled(runTasks, 10);
471+
const runResults = await pooledAllSettled(runTasks, HOT_RUNS_CONCURRENCY);
465472
for (const result of runResults) {
466473
if (result.status === "fulfilled") {
467474
runUpdates.set(result.value.id, result.value);
@@ -474,18 +481,17 @@ export async function fetchHotData(): Promise<{
474481
// The freshly rebuilt sets are authoritative — evicting from them based on
475482
// stale fetch results would corrupt the new data.
476483
if (generation === _hotPollGeneration) {
477-
// Evict settled PRs
484+
// Evict settled PRs using inverse index for O(1) lookup
478485
for (const [databaseId, upd] of prUpdates) {
479486
if (
480487
upd.state === "CLOSED" ||
481488
upd.state === "MERGED" ||
482489
(upd.checkStatus !== "pending" && upd.checkStatus !== null)
483490
) {
484-
for (const [nodeId, id] of _hotPRs) {
485-
if (id === databaseId) {
486-
_hotPRs.delete(nodeId);
487-
break;
488-
}
491+
const nodeId = _hotPRsByDbId.get(databaseId);
492+
if (nodeId) {
493+
_hotPRs.delete(nodeId);
494+
_hotPRsByDbId.delete(databaseId);
489495
}
490496
}
491497
}
@@ -525,6 +531,7 @@ export function createHotPollCoordinator(
525531
const MAX_BACKOFF_MULTIPLIER = 8; // caps at 8× the base interval
526532

527533
function destroy(): void {
534+
// Invalidates any in-flight cycle(); createEffect captures the new value as the next chain's seed
528535
chainGeneration++;
529536
consecutiveFailures = 0;
530537
if (timeoutId !== null) {
@@ -548,6 +555,12 @@ export function createHotPollCoordinator(
548555
return;
549556
}
550557

558+
// Skip fetch when no authenticated client (e.g., mid-logout)
559+
if (!getClient()) {
560+
schedule(myGeneration);
561+
return;
562+
}
563+
551564
try {
552565
const { prUpdates, runUpdates, generation, hadErrors } = await fetchHotData();
553566
if (myGeneration !== chainGeneration) return; // Chain destroyed during fetch
@@ -556,10 +569,13 @@ export function createHotPollCoordinator(
556569
} else {
557570
consecutiveFailures = 0;
558571
}
559-
onHotData(prUpdates, runUpdates, generation);
572+
if (prUpdates.size > 0 || runUpdates.size > 0) {
573+
onHotData(prUpdates, runUpdates, generation);
574+
}
560575
} catch (err) {
561576
consecutiveFailures++;
562-
console.warn(`[hot-poll] cycle failed (${consecutiveFailures}x):`, err);
577+
const message = err instanceof Error ? err.message : "Unknown hot-poll error";
578+
pushError("hot-poll", message, true);
563579
}
564580

565581
schedule(myGeneration);

src/app/stores/config.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,12 @@ export function loadConfig(): Config {
7171
export const [config, setConfig] = createStore<Config>(loadConfig());
7272

7373
export function updateConfig(partial: Partial<Config>): void {
74+
// Validate partial update through Zod to enforce schema bounds (e.g., min/max)
75+
const validated = ConfigSchema.partial().safeParse(partial);
76+
const safe = validated.success ? validated.data : partial;
7477
setConfig(
7578
produce((draft) => {
76-
Object.assign(draft, partial);
79+
Object.assign(draft, safe);
7780
})
7881
);
7982
}

tests/components/DashboardPage.test.tsx

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,12 @@ vi.mock("../../src/app/lib/errors", () => ({
5959
// capturedFetchAll is populated by the createPollCoordinator mock each time
6060
// the module is reset and DashboardPage re-mounts, creating a fresh coordinator.
6161
let capturedFetchAll: (() => Promise<DashboardData>) | null = null;
62+
// capturedOnHotData is populated by the createHotPollCoordinator mock
63+
let capturedOnHotData: ((
64+
prUpdates: Map<number, { state: string; checkStatus: string; mergeStateStatus: string; reviewDecision: string | null }>,
65+
runUpdates: Map<number, { id: number; status: string; conclusion: string | null; updatedAt: string; completedAt: string | null }>,
66+
generation: number,
67+
) => void) | null = null;
6268

6369
// DashboardPage and pollService are imported dynamically after each vi.resetModules()
6470
// so the module-level _coordinator variable is always fresh (null) per test.
@@ -99,7 +105,12 @@ beforeEach(async () => {
99105
};
100106
}
101107
),
102-
createHotPollCoordinator: vi.fn().mockReturnValue({ destroy: vi.fn() }),
108+
createHotPollCoordinator: vi.fn().mockImplementation(
109+
(_getInterval: unknown, onHotData: typeof capturedOnHotData) => {
110+
capturedOnHotData = onHotData;
111+
return { destroy: vi.fn() };
112+
}
113+
),
103114
rebuildHotSets: vi.fn(),
104115
clearHotSets: vi.fn(),
105116
getHotPollGeneration: vi.fn().mockReturnValue(0),
@@ -113,6 +124,7 @@ beforeEach(async () => {
113124

114125
mockLocationReplace.mockClear();
115126
capturedFetchAll = null;
127+
capturedOnHotData = null;
116128
vi.mocked(authStore.clearAuth).mockClear();
117129
vi.mocked(pollService.fetchAllData).mockResolvedValue({
118130
issues: [],
@@ -352,3 +364,82 @@ describe("DashboardPage — onAuthCleared integration", () => {
352364
});
353365
});
354366
});
367+
368+
describe("DashboardPage — onHotData integration", () => {
369+
it("applies hot poll PR status updates to the store", async () => {
370+
const testPR = makePullRequest({
371+
id: 42,
372+
checkStatus: "pending",
373+
state: "open",
374+
reviewDecision: null,
375+
});
376+
vi.mocked(pollService.fetchAllData).mockResolvedValue({
377+
issues: [],
378+
pullRequests: [testPR],
379+
workflowRuns: [],
380+
errors: [],
381+
});
382+
render(() => <DashboardPage />);
383+
await waitFor(() => {
384+
expect(capturedOnHotData).not.toBeNull();
385+
});
386+
387+
// Verify initial state shows pending
388+
const user = userEvent.setup();
389+
await user.click(screen.getByText("Pull Requests"));
390+
await waitFor(() => {
391+
expect(screen.getByLabelText("Checks in progress")).toBeTruthy();
392+
});
393+
394+
// Simulate hot poll returning a status update (generation=0 matches default mock)
395+
const prUpdates = new Map([[42, {
396+
state: "OPEN",
397+
checkStatus: "success" as const,
398+
mergeStateStatus: "CLEAN",
399+
reviewDecision: "APPROVED" as const,
400+
}]]);
401+
capturedOnHotData!(prUpdates, new Map(), 0);
402+
403+
// The StatusDot should update from "Checks in progress" to "All checks passed"
404+
await waitFor(() => {
405+
expect(screen.getByLabelText("All checks passed")).toBeTruthy();
406+
});
407+
});
408+
409+
it("discards stale hot poll updates when generation mismatches", async () => {
410+
const testPR = makePullRequest({
411+
id: 43,
412+
checkStatus: "pending",
413+
state: "open",
414+
});
415+
vi.mocked(pollService.fetchAllData).mockResolvedValue({
416+
issues: [],
417+
pullRequests: [testPR],
418+
workflowRuns: [],
419+
errors: [],
420+
});
421+
render(() => <DashboardPage />);
422+
await waitFor(() => {
423+
expect(capturedOnHotData).not.toBeNull();
424+
});
425+
426+
const user = userEvent.setup();
427+
await user.click(screen.getByText("Pull Requests"));
428+
await waitFor(() => {
429+
expect(screen.getByLabelText("Checks in progress")).toBeTruthy();
430+
});
431+
432+
// Send update with stale generation (999 !== mock default of 0)
433+
const prUpdates = new Map([[43, {
434+
state: "OPEN",
435+
checkStatus: "success" as const,
436+
mergeStateStatus: "CLEAN",
437+
reviewDecision: null,
438+
}]]);
439+
capturedOnHotData!(prUpdates, new Map(), 999);
440+
441+
// PR should still show pending — stale update was discarded
442+
expect(screen.getByLabelText("Checks in progress")).toBeTruthy();
443+
expect(screen.queryByLabelText("All checks passed")).toBeNull();
444+
});
445+
});

tests/lib/notifications.test.ts

Lines changed: 4 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,34 +6,16 @@ import {
66
_resetNotificationState,
77
type NewItems,
88
} from "../../src/app/lib/notifications";
9-
import type { Config } from "../../src/app/stores/config";
9+
import { ConfigSchema, type Config } from "../../src/app/stores/config";
1010
import type { DashboardData } from "../../src/app/services/poll";
1111
import type { Issue, PullRequest, WorkflowRun } from "../../src/app/services/api";
1212

1313
// ── Fixtures ──────────────────────────────────────────────────────────────────
1414

1515
function makeConfig(overrides: Partial<Config["notifications"]> = {}): Config {
16-
return {
17-
selectedOrgs: [],
18-
selectedRepos: [],
19-
refreshInterval: 300,
20-
hotPollInterval: 30,
21-
maxWorkflowsPerRepo: 5,
22-
maxRunsPerWorkflow: 3,
23-
notifications: {
24-
enabled: true,
25-
issues: true,
26-
pullRequests: true,
27-
workflowRuns: true,
28-
...overrides,
29-
},
30-
theme: "light",
31-
viewDensity: "comfortable",
32-
itemsPerPage: 25,
33-
defaultTab: "issues",
34-
rememberLastTab: true,
35-
onboardingComplete: false,
36-
};
16+
return ConfigSchema.parse({
17+
notifications: { enabled: true, ...overrides },
18+
});
3719
}
3820

3921
function makeIssue(id: number): Issue {

0 commit comments

Comments
 (0)