feat(FN-1747): merge fusion/fn-1747
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user