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:
@@ -207,7 +207,7 @@ describe("useBadgeWebSocket", () => {
|
|||||||
expect(subscribeMsg).toBeDefined();
|
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());
|
const { result } = renderHook(() => useBadgeWebSocket());
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -240,7 +240,76 @@ describe("useBadgeWebSocket", () => {
|
|||||||
});
|
});
|
||||||
expect(unsubscribeMsg).toBeDefined();
|
expect(unsubscribeMsg).toBeDefined();
|
||||||
expect(MockWebSocket.instances[0].close).toHaveBeenCalled();
|
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", () => {
|
it("shares a single websocket and ref-counted subscription across hook instances", () => {
|
||||||
@@ -302,6 +371,41 @@ describe("useBadgeWebSocket", () => {
|
|||||||
expect(unsubscribeMsg).toBeDefined();
|
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", () => {
|
describe("projectId support", () => {
|
||||||
it("includes projectId in WebSocket URL when provided", () => {
|
it("includes projectId in WebSocket URL when provided", () => {
|
||||||
const { result } = renderHook(() => useBadgeWebSocket("proj-123"));
|
const { result } = renderHook(() => useBadgeWebSocket("proj-123"));
|
||||||
@@ -395,6 +499,32 @@ describe("useBadgeWebSocket", () => {
|
|||||||
expect(newSubscribe).toBeGreaterThanOrEqual(1);
|
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 () => {
|
it("clears badge updates on project change", async () => {
|
||||||
const { result, rerender } = renderHook(
|
const { result, rerender } = renderHook(
|
||||||
({ projectId }: { projectId?: string }) => useBadgeWebSocket(projectId),
|
({ projectId }: { projectId?: string }) => useBadgeWebSocket(projectId),
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ class BadgeWebSocketStore {
|
|||||||
private ws: WebSocket | null = null;
|
private ws: WebSocket | null = null;
|
||||||
private listeners = new Set<() => void>();
|
private listeners = new Set<() => void>();
|
||||||
private badgeUpdates = new Map<string, BadgeSnapshot>();
|
private badgeUpdates = new Map<string, BadgeSnapshot>();
|
||||||
|
private retainedBadgeUpdates = new Map<string, BadgeSnapshot>();
|
||||||
private subscriptionsByTask = new Map<string, Set<string>>();
|
private subscriptionsByTask = new Map<string, Set<string>>();
|
||||||
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
private reconnectDelayMs = 1_000;
|
private reconnectDelayMs = 1_000;
|
||||||
@@ -76,6 +77,7 @@ class BadgeWebSocketStore {
|
|||||||
private previousProjectIdRef: string | null = null;
|
private previousProjectIdRef: string | null = null;
|
||||||
private projectContextVersionRef = 0;
|
private projectContextVersionRef = 0;
|
||||||
private contextVersionAtStart = 0;
|
private contextVersionAtStart = 0;
|
||||||
|
private static readonly RETAINED_BADGE_CAP = 200;
|
||||||
|
|
||||||
subscribe(listener: () => void): () => void {
|
subscribe(listener: () => void): () => void {
|
||||||
this.listeners.add(listener);
|
this.listeners.add(listener);
|
||||||
@@ -127,6 +129,13 @@ class BadgeWebSocketStore {
|
|||||||
|
|
||||||
subscribeTask(hookId: string, taskId: string): void {
|
subscribeTask(hookId: string, taskId: string): void {
|
||||||
const scopedKey = toScopedKey(this.projectId, taskId);
|
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 subscribers = this.subscriptionsByTask.get(scopedKey) ?? new Set<string>();
|
||||||
const isNewSubscription = !subscribers.has(hookId);
|
const isNewSubscription = !subscribers.has(hookId);
|
||||||
subscribers.add(hookId);
|
subscribers.add(hookId);
|
||||||
@@ -150,6 +159,18 @@ class BadgeWebSocketStore {
|
|||||||
subscribers.delete(hookId);
|
subscribers.delete(hookId);
|
||||||
if (subscribers.size === 0) {
|
if (subscribers.size === 0) {
|
||||||
this.subscriptionsByTask.delete(scopedKey);
|
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.badgeUpdates.delete(scopedKey);
|
||||||
this.send({ type: "unsubscribe", taskId, projectId: this.projectId });
|
this.send({ type: "unsubscribe", taskId, projectId: this.projectId });
|
||||||
this.emit();
|
this.emit();
|
||||||
@@ -172,6 +193,7 @@ class BadgeWebSocketStore {
|
|||||||
reset(): void {
|
reset(): void {
|
||||||
this.disconnect();
|
this.disconnect();
|
||||||
this.badgeUpdates.clear();
|
this.badgeUpdates.clear();
|
||||||
|
this.retainedBadgeUpdates.clear();
|
||||||
this.subscriptionsByTask.clear();
|
this.subscriptionsByTask.clear();
|
||||||
this.shouldReconnect = false;
|
this.shouldReconnect = false;
|
||||||
this.emit();
|
this.emit();
|
||||||
|
|||||||
Reference in New Issue
Block a user