multica/apps/web/features/realtime/use-realtime-sync.ts
Naiyuan Qing 862b85e064 fix(web): DnD local-state overlay, onSettled list invalidation, WS self-event filter
- Board DnD: use local pendingMove state for instant card placement,
  bypassing TQ's async setQueryData notification delay
- useUpdateIssue: add list invalidation to onSettled (was only detail)
- use-realtime-sync: add isSelf check to specific issue WS handlers
  (prevents redundant cache writes for own mutations)
- Clean up debug console.logs from board-view, issues-page, mutations

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 10:25:35 +08:00

207 lines
7.1 KiB
TypeScript

"use client";
import { useEffect } from "react";
import { useQueryClient } from "@tanstack/react-query";
import type { WSClient } from "@/shared/api";
import { toast } from "sonner";
import { useWorkspaceStore } from "@/features/workspace";
import { useAuthStore } from "@/features/auth";
import { createLogger } from "@/shared/logger";
import { issueKeys } from "@core/issues/queries";
import {
onIssueCreated,
onIssueUpdated,
onIssueDeleted,
} from "@core/issues/ws-updaters";
import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged } from "@core/inbox/ws-updaters";
import { inboxKeys } from "@core/inbox/queries";
import { workspaceKeys } from "@core/workspace/queries";
import type {
MemberAddedPayload,
WorkspaceDeletedPayload,
MemberRemovedPayload,
IssueUpdatedPayload,
IssueCreatedPayload,
IssueDeletedPayload,
InboxNewPayload,
} from "@/shared/types";
const logger = createLogger("realtime-sync");
/**
* Centralized WS → store sync. Called once from WSProvider.
*
* Uses the "WS as invalidation signal + refetch" pattern:
* - onAny handler extracts event prefix and calls the matching store refresh
* - Debounce per-prefix prevents rapid-fire refetches (e.g. bulk issue updates)
* - Precise handlers only for side effects (toast, navigation, self-check)
*
* Per-page events (comments, activity, subscribers, daemon) are still handled
* by individual components via useWSEvent — not here.
*/
export function useRealtimeSync(ws: WSClient | null) {
const qc = useQueryClient();
// Main sync: onAny → refreshMap with debounce
useEffect(() => {
if (!ws) return;
const refreshMap: Record<string, () => void> = {
inbox: () => {
const wsId = useWorkspaceStore.getState().workspace?.id;
if (wsId) onInboxInvalidate(qc, wsId);
},
agent: () => {
const wsId = useWorkspaceStore.getState().workspace?.id;
if (wsId) qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
},
member: () => {
const wsId = useWorkspaceStore.getState().workspace?.id;
if (wsId) qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
},
workspace: () => {
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
},
skill: () => {
const wsId = useWorkspaceStore.getState().workspace?.id;
if (wsId) qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
},
};
const timers = new Map<string, ReturnType<typeof setTimeout>>();
const debouncedRefresh = (prefix: string, fn: () => void) => {
const existing = timers.get(prefix);
if (existing) clearTimeout(existing);
timers.set(
prefix,
setTimeout(() => {
timers.delete(prefix);
fn();
}, 100),
);
};
// Event types handled by specific handlers below — skip generic refresh
const specificEvents = new Set([
"issue:updated", "issue:created", "issue:deleted", "inbox:new",
]);
const unsubAny = ws.onAny((msg) => {
const myUserId = useAuthStore.getState().user?.id;
if (msg.actor_id && msg.actor_id === myUserId) {
logger.debug("skipping self-event", msg.type);
return;
}
if (specificEvents.has(msg.type)) return;
const prefix = msg.type.split(":")[0] ?? "";
const refresh = refreshMap[prefix];
if (refresh) debouncedRefresh(prefix, refresh);
});
// --- Specific event handlers (granular updates, no full refetch) ---
// NOTE: ws.on() passes msg.payload (no actor_id). Self-event suppression
// requires WSClient changes to expose actor_id — tracked as separate task.
const unsubIssueUpdated = ws.on("issue:updated", (p) => {
const { issue } = p as IssueUpdatedPayload;
if (!issue?.id) return;
const wsId = useWorkspaceStore.getState().workspace?.id;
if (wsId) {
onIssueUpdated(qc, wsId, issue);
if (issue.status) {
onInboxIssueStatusChanged(qc, wsId, issue.id, issue.status);
}
}
});
const unsubIssueCreated = ws.on("issue:created", (p) => {
const { issue } = p as IssueCreatedPayload;
if (!issue) return;
const wsId = useWorkspaceStore.getState().workspace?.id;
if (wsId) onIssueCreated(qc, wsId, issue);
});
const unsubIssueDeleted = ws.on("issue:deleted", (p) => {
const { issue_id } = p as IssueDeletedPayload;
if (!issue_id) return;
const wsId = useWorkspaceStore.getState().workspace?.id;
if (wsId) onIssueDeleted(qc, wsId, issue_id);
});
const unsubInboxNew = ws.on("inbox:new", (p) => {
const { item } = p as InboxNewPayload;
if (!item) return;
const wsId = useWorkspaceStore.getState().workspace?.id;
if (wsId) onInboxNew(qc, wsId, item);
});
// --- Side-effect handlers (toast, navigation) ---
const unsubWsDeleted = ws.on("workspace:deleted", (p) => {
const { workspace_id } = p as WorkspaceDeletedPayload;
const currentWs = useWorkspaceStore.getState().workspace;
if (currentWs?.id === workspace_id) {
logger.warn("current workspace deleted, switching");
toast.info("This workspace was deleted");
useWorkspaceStore.getState().refreshWorkspaces();
}
});
const unsubMemberRemoved = ws.on("member:removed", (p) => {
const { user_id } = p as MemberRemovedPayload;
const myUserId = useAuthStore.getState().user?.id;
if (user_id === myUserId) {
logger.warn("removed from workspace, switching");
toast.info("You were removed from this workspace");
useWorkspaceStore.getState().refreshWorkspaces();
}
});
const unsubMemberAdded = ws.on("member:added", (p) => {
const { member, workspace_name } = p as MemberAddedPayload;
const myUserId = useAuthStore.getState().user?.id;
if (member.user_id === myUserId) {
useWorkspaceStore.getState().refreshWorkspaces();
toast.info(
`You were invited to ${workspace_name ?? "a workspace"}`,
);
}
});
return () => {
unsubAny();
unsubIssueUpdated();
unsubIssueCreated();
unsubIssueDeleted();
unsubInboxNew();
unsubWsDeleted();
unsubMemberRemoved();
unsubMemberAdded();
timers.forEach(clearTimeout);
timers.clear();
};
}, [ws, qc]);
// Reconnect → refetch all data to recover missed events
useEffect(() => {
if (!ws) return;
const unsub = ws.onReconnect(async () => {
logger.info("reconnected, refetching all data");
try {
const wsId = useWorkspaceStore.getState().workspace?.id;
if (wsId) {
qc.invalidateQueries({ queryKey: issueKeys.all(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.all(wsId) });
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
}
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
} catch (e) {
logger.error("reconnect refetch failed", e);
}
});
return unsub;
}, [ws, qc]);
}