Merge pull request #494 from multica-ai/fix/inbox-stale-timeline-cache

fix(web): add global WS handlers for per-issue cache invalidation
This commit is contained in:
Naiyuan Qing 2026-04-08 15:23:45 +08:00 committed by GitHub
commit 1903b886f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -24,6 +24,16 @@ import type {
IssueCreatedPayload, IssueCreatedPayload,
IssueDeletedPayload, IssueDeletedPayload,
InboxNewPayload, InboxNewPayload,
CommentCreatedPayload,
CommentUpdatedPayload,
CommentDeletedPayload,
ActivityCreatedPayload,
ReactionAddedPayload,
ReactionRemovedPayload,
IssueReactionAddedPayload,
IssueReactionRemovedPayload,
SubscriberAddedPayload,
SubscriberRemovedPayload,
} from "@/shared/types"; } from "@/shared/types";
const logger = createLogger("realtime-sync"); const logger = createLogger("realtime-sync");
@ -36,8 +46,9 @@ const logger = createLogger("realtime-sync");
* - Debounce per-prefix prevents rapid-fire refetches (e.g. bulk issue updates) * - Debounce per-prefix prevents rapid-fire refetches (e.g. bulk issue updates)
* - Precise handlers only for side effects (toast, navigation, self-check) * - Precise handlers only for side effects (toast, navigation, self-check)
* *
* Per-page events (comments, activity, subscribers, daemon) are still handled * Per-issue events (comments, activity, reactions, subscribers) are handled
* by individual components via useWSEvent not here. * both here (invalidation fallback) and by per-page useWSEvent hooks (granular
* updates). Daemon events are handled by individual components only.
*/ */
export function useRealtimeSync(ws: WSClient | null) { export function useRealtimeSync(ws: WSClient | null) {
const qc = useQueryClient(); const qc = useQueryClient();
@ -83,6 +94,11 @@ export function useRealtimeSync(ws: WSClient | null) {
// Event types handled by specific handlers below — skip generic refresh // Event types handled by specific handlers below — skip generic refresh
const specificEvents = new Set([ const specificEvents = new Set([
"issue:updated", "issue:created", "issue:deleted", "inbox:new", "issue:updated", "issue:created", "issue:deleted", "inbox:new",
"comment:created", "comment:updated", "comment:deleted",
"activity:created",
"reaction:added", "reaction:removed",
"issue_reaction:added", "issue_reaction:removed",
"subscriber:added", "subscriber:removed",
]); ]);
const unsubAny = ws.onAny((msg) => { const unsubAny = ws.onAny((msg) => {
@ -130,6 +146,68 @@ export function useRealtimeSync(ws: WSClient | null) {
if (wsId) onInboxNew(qc, wsId, item); if (wsId) onInboxNew(qc, wsId, item);
}); });
// --- Timeline event handlers (global fallback) ---
// These events are also handled granularly by useIssueTimeline when
// IssueDetail is mounted. This global handler ensures the timeline cache
// is invalidated even when IssueDetail is unmounted, so stale data
// isn't served on next mount (staleTime: Infinity relies on this).
const invalidateTimeline = (issueId: string) => {
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });
};
const unsubCommentCreated = ws.on("comment:created", (p) => {
const { comment } = p as CommentCreatedPayload;
if (comment?.issue_id) invalidateTimeline(comment.issue_id);
});
const unsubCommentUpdated = ws.on("comment:updated", (p) => {
const { comment } = p as CommentUpdatedPayload;
if (comment?.issue_id) invalidateTimeline(comment.issue_id);
});
const unsubCommentDeleted = ws.on("comment:deleted", (p) => {
const { issue_id } = p as CommentDeletedPayload;
if (issue_id) invalidateTimeline(issue_id);
});
const unsubActivityCreated = ws.on("activity:created", (p) => {
const { issue_id } = p as ActivityCreatedPayload;
if (issue_id) invalidateTimeline(issue_id);
});
const unsubReactionAdded = ws.on("reaction:added", (p) => {
const { issue_id } = p as ReactionAddedPayload;
if (issue_id) invalidateTimeline(issue_id);
});
const unsubReactionRemoved = ws.on("reaction:removed", (p) => {
const { issue_id } = p as ReactionRemovedPayload;
if (issue_id) invalidateTimeline(issue_id);
});
// --- Issue-level reactions & subscribers (global fallback) ---
const unsubIssueReactionAdded = ws.on("issue_reaction:added", (p) => {
const { issue_id } = p as IssueReactionAddedPayload;
if (issue_id) qc.invalidateQueries({ queryKey: issueKeys.reactions(issue_id) });
});
const unsubIssueReactionRemoved = ws.on("issue_reaction:removed", (p) => {
const { issue_id } = p as IssueReactionRemovedPayload;
if (issue_id) qc.invalidateQueries({ queryKey: issueKeys.reactions(issue_id) });
});
const unsubSubscriberAdded = ws.on("subscriber:added", (p) => {
const { issue_id } = p as SubscriberAddedPayload;
if (issue_id) qc.invalidateQueries({ queryKey: issueKeys.subscribers(issue_id) });
});
const unsubSubscriberRemoved = ws.on("subscriber:removed", (p) => {
const { issue_id } = p as SubscriberRemovedPayload;
if (issue_id) qc.invalidateQueries({ queryKey: issueKeys.subscribers(issue_id) });
});
// --- Side-effect handlers (toast, navigation) --- // --- Side-effect handlers (toast, navigation) ---
const unsubWsDeleted = ws.on("workspace:deleted", (p) => { const unsubWsDeleted = ws.on("workspace:deleted", (p) => {
@ -169,6 +247,16 @@ export function useRealtimeSync(ws: WSClient | null) {
unsubIssueCreated(); unsubIssueCreated();
unsubIssueDeleted(); unsubIssueDeleted();
unsubInboxNew(); unsubInboxNew();
unsubCommentCreated();
unsubCommentUpdated();
unsubCommentDeleted();
unsubActivityCreated();
unsubReactionAdded();
unsubReactionRemoved();
unsubIssueReactionAdded();
unsubIssueReactionRemoved();
unsubSubscriberAdded();
unsubSubscriberRemoved();
unsubWsDeleted(); unsubWsDeleted();
unsubMemberRemoved(); unsubMemberRemoved();
unsubMemberAdded(); unsubMemberAdded();