feat(FN-4370): complete Step 3 — retain badge snapshots across unsubscribes

Fusion-Task-Id: FN-4370
Fusion-Task-Lineage: c18188d5-c478-4233-943f-943b0125b271
This commit is contained in:
Fusion
2026-05-13 14:59:53 -07:00
committed by gsxdsm
parent 89ef3704ba
commit bcad65e84e
2 changed files with 154 additions and 2 deletions

View File

@@ -207,7 +207,7 @@ describe("useBadgeWebSocket", () => {
expect(subscribeMsg).toBeDefined();
});
it("sends unsubscribe, retains cached state, and closes the socket when the final subscription is removed", () => {
it("sends unsubscribe, moves cached state to retention, and closes the socket when the final subscription is removed", () => {
const { result } = renderHook(() => useBadgeWebSocket());
act(() => {
@@ -240,7 +240,76 @@ describe("useBadgeWebSocket", () => {
});
expect(unsubscribeMsg).toBeDefined();
expect(MockWebSocket.instances[0].close).toHaveBeenCalled();
expect(result.current.badgeUpdates.has("default:FN-063")).toBe(true);
expect(result.current.badgeUpdates.has("default:FN-063")).toBe(false);
});
it("restores retained snapshot immediately after re-subscribing", () => {
const { result } = renderHook(() => useBadgeWebSocket());
act(() => {
result.current.subscribeToBadge("FN-063");
MockWebSocket.instances[0].emitOpen();
MockWebSocket.instances[0].emitMessage({
type: "badge:updated",
taskId: "FN-063",
prInfo: {
url: "https://github.com/owner/repo/pull/1",
number: 1,
status: "open",
title: "Tracked PR",
headBranch: "feature/test",
baseBranch: "main",
commentCount: 0,
},
timestamp: "2026-03-30T12:00:00.000Z",
});
result.current.unsubscribeFromBadge("FN-063");
});
expect(result.current.badgeUpdates.has("default:FN-063")).toBe(false);
act(() => {
result.current.subscribeToBadge("FN-063");
});
expect(result.current.badgeUpdates.get("default:FN-063")?.prInfo?.number).toBe(1);
});
it("evicts oldest retained snapshot when retention cap is exceeded", () => {
const { result } = renderHook(() => useBadgeWebSocket());
act(() => {
MockWebSocket.instances[0]?.emitOpen();
for (let i = 1; i <= 201; i++) {
const taskId = `FN-${String(i).padStart(3, "0")}`;
result.current.subscribeToBadge(taskId);
MockWebSocket.instances[0].emitMessage({
type: "badge:updated",
taskId,
prInfo: {
url: `https://github.com/owner/repo/pull/${i}`,
number: i,
status: "open",
title: `Tracked PR ${i}`,
headBranch: "feature/test",
baseBranch: "main",
commentCount: 0,
},
timestamp: "2026-03-30T12:00:00.000Z",
});
result.current.unsubscribeFromBadge(taskId);
}
});
act(() => {
result.current.subscribeToBadge("FN-001");
});
expect(result.current.badgeUpdates.get("default:FN-001")).toBeUndefined();
act(() => {
result.current.subscribeToBadge("FN-201");
});
expect(result.current.badgeUpdates.get("default:FN-201")?.prInfo?.number).toBe(201);
});
it("shares a single websocket and ref-counted subscription across hook instances", () => {
@@ -302,6 +371,41 @@ describe("useBadgeWebSocket", () => {
expect(unsubscribeMsg).toBeDefined();
});
it("reset clears retained snapshots", () => {
const { result, unmount } = renderHook(() => useBadgeWebSocket());
act(() => {
result.current.subscribeToBadge("FN-063");
MockWebSocket.instances[0].emitOpen();
MockWebSocket.instances[0].emitMessage({
type: "badge:updated",
taskId: "FN-063",
prInfo: {
url: "https://github.com/owner/repo/pull/1",
number: 1,
status: "open",
title: "Tracked PR",
headBranch: "feature/test",
baseBranch: "main",
commentCount: 0,
},
timestamp: "2026-03-30T12:00:00.000Z",
});
result.current.unsubscribeFromBadge("FN-063");
});
unmount();
__resetBadgeWebSocketStoreForTests();
const next = renderHook(() => useBadgeWebSocket());
act(() => {
next.result.current.subscribeToBadge("FN-063");
});
expect(next.result.current.badgeUpdates.get("default:FN-063")).toBeUndefined();
next.unmount();
});
describe("projectId support", () => {
it("includes projectId in WebSocket URL when provided", () => {
const { result } = renderHook(() => useBadgeWebSocket("proj-123"));
@@ -395,6 +499,32 @@ describe("useBadgeWebSocket", () => {
expect(newSubscribe).toBeGreaterThanOrEqual(1);
});
it("invalidates retained snapshots on project change", () => {
const { result, rerender } = renderHook(
({ projectId }: { projectId?: string }) => useBadgeWebSocket(projectId),
{ initialProps: { projectId: "proj-A" } },
);
act(() => {
result.current.subscribeToBadge("FN-063");
MockWebSocket.instances[0].emitOpen();
MockWebSocket.instances[0].emitMessage({
type: "badge:updated",
taskId: "FN-063",
prInfo: { url: "https://github.com/owner/repo/pull/1", number: 1, status: "open", title: "Test PR", headBranch: "feat", baseBranch: "main", commentCount: 0 },
timestamp: "2026-03-30T12:00:00.000Z",
});
result.current.unsubscribeFromBadge("FN-063");
});
rerender({ projectId: "proj-B" });
act(() => {
result.current.subscribeToBadge("FN-063");
});
expect(result.current.badgeUpdates.get("proj-B:FN-063")).toBeUndefined();
});
it("clears badge updates on project change", async () => {
const { result, rerender } = renderHook(
({ projectId }: { projectId?: string }) => useBadgeWebSocket(projectId),

View File

@@ -60,6 +60,7 @@ class BadgeWebSocketStore {
private ws: WebSocket | null = null;
private listeners = new Set<() => void>();
private badgeUpdates = new Map<string, BadgeSnapshot>();
private retainedBadgeUpdates = new Map<string, BadgeSnapshot>();
private subscriptionsByTask = new Map<string, Set<string>>();
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
private reconnectDelayMs = 1_000;
@@ -76,6 +77,7 @@ class BadgeWebSocketStore {
private previousProjectIdRef: string | null = null;
private projectContextVersionRef = 0;
private contextVersionAtStart = 0;
private static readonly RETAINED_BADGE_CAP = 200;
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
@@ -127,6 +129,13 @@ class BadgeWebSocketStore {
subscribeTask(hookId: string, taskId: string): void {
const scopedKey = toScopedKey(this.projectId, taskId);
const retainedSnapshot = this.retainedBadgeUpdates.get(scopedKey);
if (retainedSnapshot && !this.badgeUpdates.has(scopedKey)) {
this.badgeUpdates.set(scopedKey, retainedSnapshot);
this.retainedBadgeUpdates.delete(scopedKey);
this.emit();
}
const subscribers = this.subscriptionsByTask.get(scopedKey) ?? new Set<string>();
const isNewSubscription = !subscribers.has(hookId);
subscribers.add(hookId);
@@ -150,6 +159,18 @@ class BadgeWebSocketStore {
subscribers.delete(hookId);
if (subscribers.size === 0) {
this.subscriptionsByTask.delete(scopedKey);
const snapshot = this.badgeUpdates.get(scopedKey);
if (snapshot) {
// Keep a bounded cache of last-known snapshots so cards can restore badge state
// immediately after transient viewport unsubscriptions. Cap prevents unbounded growth.
this.retainedBadgeUpdates.delete(scopedKey);
this.retainedBadgeUpdates.set(scopedKey, snapshot);
while (this.retainedBadgeUpdates.size > BadgeWebSocketStore.RETAINED_BADGE_CAP) {
const oldestKey = this.retainedBadgeUpdates.keys().next().value;
if (!oldestKey) break;
this.retainedBadgeUpdates.delete(oldestKey);
}
}
this.badgeUpdates.delete(scopedKey);
this.send({ type: "unsubscribe", taskId, projectId: this.projectId });
this.emit();
@@ -172,6 +193,7 @@ class BadgeWebSocketStore {
reset(): void {
this.disconnect();
this.badgeUpdates.clear();
this.retainedBadgeUpdates.clear();
this.subscriptionsByTask.clear();
this.shouldReconnect = false;
this.emit();