Skip to content

Commit 41215bc

Browse files
committed
fix(poll): uses serialized keys for notification reset
1 parent 761b500 commit 41215bc

File tree

2 files changed

+82
-20
lines changed

2 files changed

+82
-20
lines changed

src/app/services/poll.ts

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -87,35 +87,35 @@ export function resetPollState(): void {
8787
// Auto-reset poll state on logout (avoids circular dep with auth.ts)
8888
onAuthCleared(resetPollState);
8989

90-
// When tracked users change, reset notification state so the next poll cycle
91-
// silently seeds all items (including the new tracked user's) without flooding
92-
// the user with "new item" notifications for pre-existing content.
93-
// Use a flag to skip the initial run (module-level mount).
94-
// Wrapped in createRoot to provide a reactive owner at module scope (per SolidJS gotcha).
95-
// Subscribes to array length only — fires on add/remove, not property mutations.
96-
let _trackedUsersMounted = false;
90+
// When tracked users or monitored repos change, reset notification state so the
91+
// next poll cycle silently seeds items without flooding "new item" notifications.
92+
// Tracks a serialized key (sorted logins/fullNames) so swapping entries at the
93+
// same array length still triggers the reset.
94+
let _trackedUsersKey = "";
95+
let _monitoredReposKey = "";
9796
createRoot(() => {
9897
createEffect(() => {
99-
void (config.trackedUsers?.length ?? 0);
100-
if (!_trackedUsersMounted) {
101-
_trackedUsersMounted = true;
98+
const key = (config.trackedUsers ?? []).map((u) => u.login).sort().join(",");
99+
if (_trackedUsersKey === "") {
100+
_trackedUsersKey = key;
102101
return;
103102
}
104-
untrack(() => _resetNotificationState());
103+
if (key !== _trackedUsersKey) {
104+
_trackedUsersKey = key;
105+
untrack(() => _resetNotificationState());
106+
}
105107
});
106-
});
107108

108-
// When monitoredRepos changes, reset notification state so the next poll cycle
109-
// silently seeds the newly monitored repo's items without flooding with notifications.
110-
let _monitoredReposMounted = false;
111-
createRoot(() => {
112109
createEffect(() => {
113-
void (config.monitoredRepos?.length ?? 0);
114-
if (!_monitoredReposMounted) {
115-
_monitoredReposMounted = true;
110+
const key = (config.monitoredRepos ?? []).map((r) => r.fullName).sort().join(",");
111+
if (_monitoredReposKey === "") {
112+
_monitoredReposKey = key;
116113
return;
117114
}
118-
untrack(() => _resetNotificationState());
115+
if (key !== _monitoredReposKey) {
116+
_monitoredReposKey = key;
117+
untrack(() => _resetNotificationState());
118+
}
119119
});
120120
});
121121

tests/services/api.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1615,6 +1615,68 @@ describe("fetchIssuesAndPullRequests — monitoredRepos", () => {
16151615
});
16161616
});
16171617

1618+
describe("fetchIssuesAndPullRequests — all repos monitored (edge case)", () => {
1619+
it("skips main user light search and returns items from unfiltered search only", async () => {
1620+
const queriesUsed: string[] = [];
1621+
const repo = { owner: "org", name: "repo1", fullName: "org/repo1" };
1622+
1623+
const issueNode = {
1624+
databaseId: 3001,
1625+
number: 1,
1626+
title: "All-monitored issue",
1627+
state: "open",
1628+
url: "https://github.com/org/repo1/issues/1",
1629+
createdAt: "2024-01-01T00:00:00Z",
1630+
updatedAt: "2024-01-02T00:00:00Z",
1631+
author: { login: "someone", avatarUrl: "https://avatars.githubusercontent.com/u/1" },
1632+
labels: { nodes: [] },
1633+
assignees: { nodes: [] },
1634+
repository: { nameWithOwner: "org/repo1" },
1635+
comments: { totalCount: 0 },
1636+
};
1637+
1638+
const octokit = makeOctokit(
1639+
async () => ({ data: {}, headers: {} }),
1640+
async (_query: string, variables: unknown) => {
1641+
const vars = variables as Record<string, unknown>;
1642+
if (vars.issueQ) queriesUsed.push(vars.issueQ as string);
1643+
return {
1644+
issues: { issueCount: 1, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [issueNode] },
1645+
prs: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] },
1646+
prInvolves: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] },
1647+
prReviewReq: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] },
1648+
rateLimit: { limit: 5000, remaining: 4999, resetAt: new Date(Date.now() + 3600000).toISOString() },
1649+
};
1650+
}
1651+
);
1652+
1653+
const { fetchIssuesAndPullRequests } = await import("../../src/app/services/api");
1654+
const result = await fetchIssuesAndPullRequests(
1655+
octokit as never,
1656+
[repo],
1657+
"octocat",
1658+
undefined,
1659+
undefined,
1660+
[{ fullName: "org/repo1" }] // all repos monitored
1661+
);
1662+
1663+
// Items returned from unfiltered search
1664+
expect(result.issues).toHaveLength(1);
1665+
expect(result.issues[0].id).toBe(3001);
1666+
1667+
// No involves: query (main user search skipped since normalRepos is empty)
1668+
const involvesQueries = queriesUsed.filter(q => q.includes("involves:octocat"));
1669+
expect(involvesQueries).toHaveLength(0);
1670+
1671+
// Unfiltered query was issued (no involves: qualifier)
1672+
const unfilteredQueries = queriesUsed.filter(q => !q.includes("involves:") && !q.includes("review-requested:"));
1673+
expect(unfilteredQueries.length).toBeGreaterThan(0);
1674+
1675+
// Item has no surfacedBy (unfiltered search items aren't attributed to a user)
1676+
expect(result.issues[0].surfacedBy).toBeUndefined();
1677+
});
1678+
});
1679+
16181680
// ── fetchIssuesAndPullRequests — cross-feature integration (monitored + bot) ──
16191681

16201682
describe("fetchIssuesAndPullRequests — cross-feature: monitored repo + bot tracked user", () => {

0 commit comments

Comments
 (0)