feat(FN-1747): merge fusion/fn-1747

This commit is contained in:
gsxdsm
2026-04-14 11:43:52 -07:00
parent 831477e6ea
commit 102af7f4f2
11 changed files with 286 additions and 37 deletions

View File

@@ -495,7 +495,7 @@ function TaskCardComponent({
};
}, [hasGitHubBadge, isInViewport, subscribeToBadge, task.id, unsubscribeFromBadge]);
const liveBadgeData = badgeUpdates.get(task.id);
const liveBadgeData = badgeUpdates.get(`${projectId ?? "default"}:${task.id}`);
// Compute step version for diff stats refresh when steps change
const isActiveColumn = task.column === "in-progress" || task.column === "in-review";

View File

@@ -45,7 +45,7 @@ function TaskCardBadgeComponent({ taskId, prInfo, issueInfo, updatedAt, isInView
};
}, [hasGitHubBadge, isInViewport, projectId, subscribeToBadge, taskId, unsubscribeFromBadge]);
const liveBadgeData = badgeUpdates.get(taskId);
const liveBadgeData = badgeUpdates.get(`${projectId ?? "default"}:${taskId}`);
const livePrInfo = pickPreferredBadge<PrInfo>(
liveBadgeData?.prInfo,
liveBadgeData?.timestamp,

View File

@@ -2270,7 +2270,7 @@ describe("TaskCard GitHub badges", () => {
mockUseBadgeWebSocket.mockReturnValue({
badgeUpdates: new Map([
[
"FN-099",
"default:FN-099",
{
prInfo: {
url: "https://github.com/owner/repo/pull/42",
@@ -2369,7 +2369,7 @@ describe("TaskCard GitHub badges", () => {
mockUseBadgeWebSocket.mockReturnValue({
badgeUpdates: new Map([
[
"FN-099",
"default:FN-099",
{
issueInfo: {
url: "https://github.com/owner/repo/issues/123",

View File

@@ -102,7 +102,7 @@ describe("useBadgeWebSocket", () => {
});
});
const update = result.current.badgeUpdates.get("FN-063");
const update = result.current.badgeUpdates.get("default:FN-063");
expect(update).toMatchObject({
prInfo: null,
issueInfo: {
@@ -145,7 +145,7 @@ describe("useBadgeWebSocket", () => {
});
});
expect(result.current.badgeUpdates.get("FN-063")).toMatchObject({
expect(result.current.badgeUpdates.get("default:FN-063")).toMatchObject({
prInfo: { number: 1 },
issueInfo: { number: 2 },
});
@@ -173,14 +173,15 @@ describe("useBadgeWebSocket", () => {
});
});
expect(result.current.badgeUpdates.has("FN-063")).toBe(true);
// With scoped keys, badge data is stored under "default:FN-063"
expect(result.current.badgeUpdates.has("default:FN-063")).toBe(true);
act(() => {
MockWebSocket.instances[0].emitClose(1006);
});
expect(result.current.isConnected).toBe(false);
expect(result.current.badgeUpdates.has("FN-063")).toBe(true);
expect(result.current.badgeUpdates.has("default:FN-063")).toBe(true);
act(() => {
vi.advanceTimersByTime(1_000);
@@ -224,7 +225,7 @@ describe("useBadgeWebSocket", () => {
expect(MockWebSocket.instances[0].sent).toContain(JSON.stringify({ type: "unsubscribe", taskId: "FN-063" }));
expect(MockWebSocket.instances[0].close).toHaveBeenCalled();
expect(result.current.badgeUpdates.has("FN-063")).toBe(false);
expect(result.current.badgeUpdates.has("default:FN-063")).toBe(false);
});
it("shares a single websocket and ref-counted subscription across hook instances", () => {
@@ -373,7 +374,8 @@ describe("useBadgeWebSocket", () => {
});
});
expect(result.current.badgeUpdates.has("FN-063")).toBe(true);
// With scoped keys, badge data is stored under "proj-A:FN-063"
expect(result.current.badgeUpdates.has("proj-A:FN-063")).toBe(true);
// Change project
rerender({ projectId: "proj-B" });
@@ -383,8 +385,69 @@ describe("useBadgeWebSocket", () => {
vi.advanceTimersByTime(1_000);
});
// Badge updates should be cleared
expect(result.current.badgeUpdates.has("FN-063")).toBe(false);
// Badge updates should be cleared (including old project key)
expect(result.current.badgeUpdates.has("proj-A:FN-063")).toBe(false);
});
it("isolates badge updates across projects with same task ID", async () => {
// Two hooks watching the same task ID in different projects
// Note: The singleton store only maintains one active projectId,
// so we test isolation by verifying scoped key storage works correctly
const { result: resultA, rerender: rerenderA } = renderHook(
({ projectId }: { projectId?: string }) => useBadgeWebSocket(projectId),
{ initialProps: { projectId: "proj-A" } },
);
// Subscribe to FN-063 in project A
act(() => {
resultA.current.subscribeToBadge("FN-063");
MockWebSocket.instances[0].emitOpen();
});
// Verify badge update is stored with scoped key
act(() => {
MockWebSocket.instances[0].emitMessage({
type: "badge:updated",
taskId: "FN-063",
prInfo: { url: "https://github.com/owner/repo/pull/1", number: 1, status: "merged", title: "Merged PR", headBranch: "feat", baseBranch: "main", commentCount: 0 },
timestamp: "2026-03-30T12:00:00.000Z",
});
});
expect(resultA.current.badgeUpdates.get("proj-A:FN-063")?.prInfo?.status).toBe("merged");
// Now switch to project B (simulates a different component/context)
// After this, the store's projectId is "proj-B"
rerenderA({ projectId: "proj-B" });
// Wait for reconnect
act(() => {
vi.advanceTimersByTime(1_000);
});
// Old project's cache should be cleared
expect(resultA.current.badgeUpdates.has("proj-A:FN-063")).toBe(false);
// Subscribe to the same task ID in the new project
act(() => {
resultA.current.subscribeToBadge("FN-063");
MockWebSocket.instances[1].emitOpen();
});
// Simulate badge update for project-B's FN-063 with different status
act(() => {
MockWebSocket.instances[1].emitMessage({
type: "badge:updated",
taskId: "FN-063",
prInfo: { url: "https://github.com/owner/repo/pull/2", number: 2, status: "open", title: "Open PR", headBranch: "feat2", baseBranch: "main", commentCount: 0 },
timestamp: "2026-03-30T12:01:00.000Z",
});
});
// Project B should have its own update
expect(resultA.current.badgeUpdates.get("proj-B:FN-063")?.prInfo?.status).toBe("open");
// Project A's data should not be present (overwritten by project switch)
expect(resultA.current.badgeUpdates.get("proj-A:FN-063")).toBeUndefined();
});
});
});

View File

@@ -20,6 +20,15 @@ interface StoreSnapshot {
isConnected: boolean;
}
/**
* Scoped key helper for multi-project isolation.
* Keys are formatted as `${projectId}:${taskId}` to prevent
* overlapping task IDs across projects from sharing badge state.
*/
function toScopedKey(projectId: string | null, taskId: string): string {
return `${projectId ?? "default"}:${taskId}`;
}
class BadgeWebSocketStore {
private ws: WebSocket | null = null;
private listeners = new Set<() => void>();
@@ -52,7 +61,9 @@ class BadgeWebSocketStore {
const hadSubscriptions = this.subscriptionsByTask.size > 0;
// Collect all (hookId, taskId) pairs that were subscribed
const previousSubscriptions: Array<{ hookId: string; taskId: string }> = [];
for (const [taskId, subscribers] of this.subscriptionsByTask) {
for (const [scopedTaskId, subscribers] of this.subscriptionsByTask) {
// Extract taskId from scoped key (format: "projectId:taskId")
const taskId = scopedTaskId.split(":").slice(1).join(":");
for (const hookId of subscribers) {
previousSubscriptions.push({ hookId, taskId });
}
@@ -63,39 +74,45 @@ class BadgeWebSocketStore {
// Re-subscribe to all previous subscriptions after project change
// This ensures badge subscriptions survive project switches
// Note: we restore subscriptions BEFORE calling connect() so that
// onopen will send the subscribe messages over the new socket
if (hadSubscriptions) {
for (const { hookId, taskId } of previousSubscriptions) {
this.subscriptionsByTask.set(taskId, new Set([hookId]));
const scopedKey = toScopedKey(this.projectId, taskId);
this.subscriptionsByTask.set(scopedKey, new Set([hookId]));
}
// Set shouldReconnect BEFORE calling connect() to ensure the connection is made
this.shouldReconnect = this.subscriptionsByTask.size > 0;
// Note: we restore subscriptions BEFORE calling connect() so that
// onopen will send the subscribe messages over the new socket
this.connect();
}
}
subscribeTask(hookId: string, taskId: string): void {
const subscribers = this.subscriptionsByTask.get(taskId) ?? new Set<string>();
const beforeSize = subscribers.size;
const scopedKey = toScopedKey(this.projectId, taskId);
const subscribers = this.subscriptionsByTask.get(scopedKey) ?? new Set<string>();
const isNewSubscription = !subscribers.has(hookId);
subscribers.add(hookId);
this.subscriptionsByTask.set(taskId, subscribers);
this.subscriptionsByTask.set(scopedKey, subscribers);
this.shouldReconnect = this.subscriptionsByTask.size > 0;
this.connect();
if (beforeSize === 0) {
// Only send subscribe message if this is a genuinely new subscription
// (not a re-subscription from project switch or unmount/remount)
if (isNewSubscription) {
this.send({ type: "subscribe", taskId });
}
}
unsubscribeTask(hookId: string, taskId: string): void {
const subscribers = this.subscriptionsByTask.get(taskId);
const scopedKey = toScopedKey(this.projectId, taskId);
const subscribers = this.subscriptionsByTask.get(scopedKey);
if (!subscribers) return;
subscribers.delete(hookId);
if (subscribers.size === 0) {
this.subscriptionsByTask.delete(taskId);
this.badgeUpdates.delete(taskId);
this.subscriptionsByTask.delete(scopedKey);
this.badgeUpdates.delete(scopedKey);
this.send({ type: "unsubscribe", taskId });
this.emit();
}
@@ -107,7 +124,9 @@ class BadgeWebSocketStore {
}
cleanupHook(hookId: string): void {
for (const taskId of [...this.subscriptionsByTask.keys()]) {
for (const scopedTaskId of [...this.subscriptionsByTask.keys()]) {
// Extract taskId from scoped key
const taskId = scopedTaskId.split(":").slice(1).join(":");
this.unsubscribeTask(hookId, taskId);
}
}
@@ -142,7 +161,13 @@ class BadgeWebSocketStore {
this.reconnectDelayMs = 1_000;
this.emit();
for (const taskId of this.subscriptionsByTask.keys()) {
// Extract raw taskIds from scoped keys (format: "projectId:taskId")
const uniqueTaskIds = new Set<string>();
for (const scopedKey of this.subscriptionsByTask.keys()) {
const taskId = scopedKey.split(":").slice(1).join(":");
uniqueTaskIds.add(taskId);
}
for (const taskId of uniqueTaskIds) {
this.send({ type: "subscribe", taskId });
}
};
@@ -154,8 +179,9 @@ class BadgeWebSocketStore {
return;
}
const previous = this.badgeUpdates.get(message.taskId);
this.badgeUpdates.set(message.taskId, {
const scopedKey = toScopedKey(this.projectId, message.taskId);
const previous = this.badgeUpdates.get(scopedKey);
this.badgeUpdates.set(scopedKey, {
prInfo: hasMessageField(message, "prInfo") ? message.prInfo ?? null : previous?.prInfo,
issueInfo: hasMessageField(message, "issueInfo") ? message.issueInfo ?? null : previous?.issueInfo,
timestamp: message.timestamp,