Merge pull request #488 from multica-ai/fix/ws-self-event-idempotent

fix(web): replace WS self-event filtering with idempotent cache updates
This commit is contained in:
Naiyuan Qing 2026-04-08 14:00:18 +08:00 committed by GitHub
commit 990cc8b3ae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 20 additions and 32 deletions

View file

@ -21,11 +21,14 @@ export function useCreateIssue() {
mutationFn: (data: CreateIssueRequest) => api.createIssue(data), mutationFn: (data: CreateIssueRequest) => api.createIssue(data),
onSuccess: (newIssue) => { onSuccess: (newIssue) => {
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) =>
old old && !old.issues.some((i) => i.id === newIssue.id)
? { ...old, issues: [...old.issues, newIssue], total: old.total + 1 } ? { ...old, issues: [...old.issues, newIssue], total: old.total + 1 }
: old, : old,
); );
}, },
onSettled: () => {
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
},
}); });
} }
@ -204,6 +207,9 @@ export function useCreateComment(issueId: string) {
}, },
); );
}, },
onSettled: () => {
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });
},
}); });
} }

View file

@ -34,8 +34,6 @@ export function useIssueReactions(issueId: string, userId?: string) {
(payload: unknown) => { (payload: unknown) => {
const { reaction, issue_id } = payload as IssueReactionAddedPayload; const { reaction, issue_id } = payload as IssueReactionAddedPayload;
if (issue_id !== issueId) return; if (issue_id !== issueId) return;
if (reaction.actor_type === "member" && reaction.actor_id === userId)
return;
qc.setQueryData<IssueReaction[]>( qc.setQueryData<IssueReaction[]>(
issueKeys.reactions(issueId), issueKeys.reactions(issueId),
(old) => { (old) => {
@ -45,7 +43,7 @@ export function useIssueReactions(issueId: string, userId?: string) {
}, },
); );
}, },
[qc, issueId, userId], [qc, issueId],
), ),
); );
@ -55,7 +53,6 @@ export function useIssueReactions(issueId: string, userId?: string) {
(payload: unknown) => { (payload: unknown) => {
const p = payload as IssueReactionRemovedPayload; const p = payload as IssueReactionRemovedPayload;
if (p.issue_id !== issueId) return; if (p.issue_id !== issueId) return;
if (p.actor_type === "member" && p.actor_id === userId) return;
qc.setQueryData<IssueReaction[]>( qc.setQueryData<IssueReaction[]>(
issueKeys.reactions(issueId), issueKeys.reactions(issueId),
(old) => (old) =>
@ -69,7 +66,7 @@ export function useIssueReactions(issueId: string, userId?: string) {
), ),
); );
}, },
[qc, issueId, userId], [qc, issueId],
), ),
); );

View file

@ -63,11 +63,6 @@ export function useIssueTimeline(issueId: string, userId?: string) {
(payload: unknown) => { (payload: unknown) => {
const { comment } = payload as CommentCreatedPayload; const { comment } = payload as CommentCreatedPayload;
if (comment.issue_id !== issueId) return; if (comment.issue_id !== issueId) return;
if (
comment.author_type === "member" &&
comment.author_id === userId
)
return;
qc.setQueryData<TimelineEntry[]>( qc.setQueryData<TimelineEntry[]>(
issueKeys.timeline(issueId), issueKeys.timeline(issueId),
(old) => { (old) => {
@ -77,7 +72,7 @@ export function useIssueTimeline(issueId: string, userId?: string) {
}, },
); );
}, },
[qc, issueId, userId], [qc, issueId],
), ),
); );
@ -161,11 +156,6 @@ export function useIssueTimeline(issueId: string, userId?: string) {
(payload: unknown) => { (payload: unknown) => {
const { reaction, issue_id } = payload as ReactionAddedPayload; const { reaction, issue_id } = payload as ReactionAddedPayload;
if (issue_id !== issueId) return; if (issue_id !== issueId) return;
if (
reaction.actor_type === "member" &&
reaction.actor_id === userId
)
return;
qc.setQueryData<TimelineEntry[]>( qc.setQueryData<TimelineEntry[]>(
issueKeys.timeline(issueId), issueKeys.timeline(issueId),
(old) => (old) =>
@ -177,7 +167,7 @@ export function useIssueTimeline(issueId: string, userId?: string) {
}), }),
); );
}, },
[qc, issueId, userId], [qc, issueId],
), ),
); );
@ -187,7 +177,6 @@ export function useIssueTimeline(issueId: string, userId?: string) {
(payload: unknown) => { (payload: unknown) => {
const p = payload as ReactionRemovedPayload; const p = payload as ReactionRemovedPayload;
if (p.issue_id !== issueId) return; if (p.issue_id !== issueId) return;
if (p.actor_type === "member" && p.actor_id === userId) return;
qc.setQueryData<TimelineEntry[]>( qc.setQueryData<TimelineEntry[]>(
issueKeys.timeline(issueId), issueKeys.timeline(issueId),
(old) => (old) =>
@ -207,7 +196,7 @@ export function useIssueTimeline(issueId: string, userId?: string) {
}), }),
); );
}, },
[qc, issueId, userId], [qc, issueId],
), ),
); );

View file

@ -4,7 +4,7 @@ import { useEffect } from "react";
import type { WSEventType } from "@/shared/types"; import type { WSEventType } from "@/shared/types";
import { useWS } from "./provider"; import { useWS } from "./provider";
type EventHandler = (payload: unknown) => void; type EventHandler = (payload: unknown, actorId?: string) => void;
/** /**
* Hook that subscribes to a WebSocket event and calls the handler. * Hook that subscribes to a WebSocket event and calls the handler.

View file

@ -22,7 +22,7 @@ const WS_URL =
? `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/ws` ? `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/ws`
: "ws://localhost:8080/ws"); : "ws://localhost:8080/ws");
type EventHandler = (payload: unknown) => void; type EventHandler = (payload: unknown, actorId?: string) => void;
interface WSContextValue { interface WSContextValue {
subscribe: (event: WSEventType, handler: EventHandler) => () => void; subscribe: (event: WSEventType, handler: EventHandler) => () => void;

View file

@ -86,20 +86,16 @@ export function useRealtimeSync(ws: WSClient | null) {
]); ]);
const unsubAny = ws.onAny((msg) => { 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; if (specificEvents.has(msg.type)) return;
const prefix = msg.type.split(":")[0] ?? ""; const prefix = msg.type.split(":")[0] ?? "";
const refresh = refreshMap[prefix]; const refresh = refreshMap[prefix];
if (refresh) debouncedRefresh(prefix, refresh); if (refresh) debouncedRefresh(prefix, refresh);
}); });
// --- Specific event handlers (granular updates, no full refetch) --- // --- Specific event handlers (granular cache updates) ---
// NOTE: ws.on() passes msg.payload (no actor_id). Self-event suppression // No self-event filtering: actor_id identifies the USER, not the TAB.
// requires WSClient changes to expose actor_id — tracked as separate task. // Filtering by actor_id would block other tabs of the same user.
// Instead, both mutations and WS handlers use dedup checks to be idempotent.
const unsubIssueUpdated = ws.on("issue:updated", (p) => { const unsubIssueUpdated = ws.on("issue:updated", (p) => {
const { issue } = p as IssueUpdatedPayload; const { issue } = p as IssueUpdatedPayload;

View file

@ -1,7 +1,7 @@
import type { WSMessage, WSEventType } from "@/shared/types"; import type { WSMessage, WSEventType } from "@/shared/types";
import { type Logger, noopLogger } from "@/shared/logger"; import { type Logger, noopLogger } from "@/shared/logger";
type EventHandler = (payload: unknown) => void; type EventHandler = (payload: unknown, actorId?: string) => void;
export class WSClient { export class WSClient {
private ws: WebSocket | null = null; private ws: WebSocket | null = null;
@ -53,7 +53,7 @@ export class WSClient {
const eventHandlers = this.handlers.get(msg.type); const eventHandlers = this.handlers.get(msg.type);
if (eventHandlers) { if (eventHandlers) {
for (const handler of eventHandlers) { for (const handler of eventHandlers) {
handler(msg.payload); handler(msg.payload, msg.actor_id);
} }
} }
for (const handler of this.anyHandlers) { for (const handler of this.anyHandlers) {