fix(web): add global WS handlers for per-issue cache invalidation

Per-issue WS events (comments, activities, reactions, subscribers) were
only handled by component-level useWSEvent hooks that unsubscribe on
unmount. With staleTime: Infinity, this left timeline/reactions/subscribers
caches silently stale — reopening an issue served cached data without
refetching, causing missing comments in inbox and issue detail views.

Add global fallback handlers in useRealtimeSync that invalidateQueries
for the affected issue on every per-issue WS event, ensuring caches are
marked stale even when IssueDetail is unmounted.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-04-08 15:04:14 +08:00
parent 7d74b1f0b9
commit 240813c605

View file

@ -24,6 +24,16 @@ import type {
IssueCreatedPayload,
IssueDeletedPayload,
InboxNewPayload,
CommentCreatedPayload,
CommentUpdatedPayload,
CommentDeletedPayload,
ActivityCreatedPayload,
ReactionAddedPayload,
ReactionRemovedPayload,
IssueReactionAddedPayload,
IssueReactionRemovedPayload,
SubscriberAddedPayload,
SubscriberRemovedPayload,
} from "@/shared/types";
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)
* - 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.
* Per-issue events (comments, activity, reactions, subscribers) are handled
* 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) {
const qc = useQueryClient();
@ -83,6 +94,11 @@ export function useRealtimeSync(ws: WSClient | null) {
// Event types handled by specific handlers below — skip generic refresh
const specificEvents = new Set([
"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) => {
@ -130,6 +146,68 @@ export function useRealtimeSync(ws: WSClient | null) {
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) ---
const unsubWsDeleted = ws.on("workspace:deleted", (p) => {
@ -169,6 +247,16 @@ export function useRealtimeSync(ws: WSClient | null) {
unsubIssueCreated();
unsubIssueDeleted();
unsubInboxNew();
unsubCommentCreated();
unsubCommentUpdated();
unsubCommentDeleted();
unsubActivityCreated();
unsubReactionAdded();
unsubReactionRemoved();
unsubIssueReactionAdded();
unsubIssueReactionRemoved();
unsubSubscriberAdded();
unsubSubscriberRemoved();
unsubWsDeleted();
unsubMemberRemoved();
unsubMemberAdded();