multica/apps/web/features/realtime/use-realtime-sync.ts
Naiyuan Qing 1ad057fb0f refactor(issues): migrate all consumers to TanStack Query (Phase 1, Commits 5-10)
- Migrate issue-detail.tsx: useQuery for issue data, useUpdateIssue/useDeleteIssue
- Migrate issues-page.tsx, my-issues-page.tsx, board-card.tsx: useQuery for list
- Migrate batch-action-toolbar.tsx, create-issue.tsx: mutation hooks
- Migrate edge consumers: mention-suggestion, mention-view, agents page, issue-mention-card
- Remove Zustand writes from WS sync (TQ cache is now sole source of truth)
- Remove useIssueStore.fetch() dependency from workspace store
- Gut useIssueStore to client-only: { activeIssueId, setActiveIssue }
- Update test wrappers with QueryClientProvider

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

198 lines
6.8 KiB
TypeScript

"use client";
import { useEffect } from "react";
import type { WSClient } from "@/shared/api";
import { toast } from "sonner";
import { useInboxStore } from "@/features/inbox";
import { useWorkspaceStore } from "@/features/workspace";
import { useAuthStore } from "@/features/auth";
import { createLogger } from "@/shared/logger";
import { api } from "@/shared/api";
import { getQueryClient } from "@core/query-client";
import { issueKeys } from "@core/issues/queries";
import {
onIssueCreated,
onIssueUpdated,
onIssueDeleted,
} from "@core/issues/ws-updaters";
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) {
// Main sync: onAny → refreshMap with debounce
useEffect(() => {
if (!ws) return;
// Event types handled by specific handlers below — skip generic refresh
const specificEvents = new Set([
"issue:updated", "issue:created", "issue:deleted", "inbox:new",
]);
const refreshMap: Record<string, () => void> = {
inbox: () => void useInboxStore.getState().fetch(),
agent: () => void useWorkspaceStore.getState().refreshAgents(),
member: () => void useWorkspaceStore.getState().refreshMembers(),
workspace: () => {
// Lightweight: only re-fetch workspace list, don't hydrate everything.
// workspace:deleted is handled by a precise side-effect handler below.
api.listWorkspaces().then((wsList) => {
const current = useWorkspaceStore.getState().workspace;
const updated = current
? wsList.find((w) => w.id === current.id)
: null;
if (updated) useWorkspaceStore.getState().updateWorkspace(updated);
}).catch((err) => {
logger.error("workspace refresh failed", err);
});
},
skill: () => void useWorkspaceStore.getState().refreshSkills(),
};
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),
);
};
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) ---
const unsubIssueUpdated = ws.on("issue:updated", (p) => {
const { issue } = p as IssueUpdatedPayload;
if (!issue?.id) return;
if (issue.status) {
useInboxStore.getState().updateIssueStatus(issue.id, issue.status);
}
const wsId = useWorkspaceStore.getState().workspace?.id;
if (wsId) onIssueUpdated(getQueryClient(), wsId, issue);
});
const unsubIssueCreated = ws.on("issue:created", (p) => {
const { issue } = p as IssueCreatedPayload;
if (!issue) return;
const wsId = useWorkspaceStore.getState().workspace?.id;
if (wsId) onIssueCreated(getQueryClient(), 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(getQueryClient(), wsId, issue_id);
});
const unsubInboxNew = ws.on("inbox:new", (p) => {
const { item } = p as InboxNewPayload;
if (item) useInboxStore.getState().addItem(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]);
// 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) {
getQueryClient().invalidateQueries({ queryKey: issueKeys.all(wsId) });
}
await Promise.all([
useInboxStore.getState().fetch(),
useWorkspaceStore.getState().refreshAgents(),
useWorkspaceStore.getState().refreshMembers(),
useWorkspaceStore.getState().refreshSkills(),
]);
} catch (e) {
logger.error("reconnect refetch failed", e);
}
});
return unsub;
}, [ws]);
}