Merge pull request #496 from multica-ai/refactor/reaction-ui-optimistic

refactor(web): migrate reaction optimistic updates to UI pattern
This commit is contained in:
Naiyuan Qing 2026-04-08 15:43:51 +08:00 committed by GitHub
commit 0dcaa60919
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 135 additions and 128 deletions

View file

@ -11,6 +11,22 @@ import type {
} from "@/shared/types";
import type { TimelineEntry, IssueSubscriber, Reaction } from "@/shared/types";
// ---------------------------------------------------------------------------
// Shared mutation variable types — used by both mutation hooks and
// useMutationState consumers to keep the type assertion in sync.
// ---------------------------------------------------------------------------
export type ToggleCommentReactionVars = {
commentId: string;
emoji: string;
existing: Reaction | undefined;
};
export type ToggleIssueReactionVars = {
emoji: string;
existing: IssueReaction | undefined;
};
// ---------------------------------------------------------------------------
// Done issue pagination
// ---------------------------------------------------------------------------
@ -334,88 +350,18 @@ export function useDeleteComment(issueId: string) {
export function useToggleCommentReaction(issueId: string) {
const qc = useQueryClient();
return useMutation({
mutationKey: ["toggleCommentReaction", issueId] as const,
mutationFn: async ({
commentId,
emoji,
existing,
}: {
commentId: string;
emoji: string;
existing: Reaction | undefined;
}) => {
}: ToggleCommentReactionVars) => {
if (existing) {
await api.removeReaction(commentId, emoji);
return null;
}
return api.addReaction(commentId, emoji);
},
onMutate: async ({ commentId, emoji, existing }) => {
await qc.cancelQueries({ queryKey: issueKeys.timeline(issueId) });
const prev = qc.getQueryData<TimelineEntry[]>(issueKeys.timeline(issueId));
if (existing) {
// Remove
qc.setQueryData<TimelineEntry[]>(
issueKeys.timeline(issueId),
(old) =>
old?.map((e) =>
e.id === commentId
? {
...e,
reactions: (e.reactions ?? []).filter(
(r) => r.id !== existing.id,
),
}
: e,
),
);
} else {
// Add temp
const tempReaction: Reaction = {
id: `temp-${Date.now()}`,
comment_id: commentId,
actor_type: "",
actor_id: "",
emoji,
created_at: new Date().toISOString(),
};
qc.setQueryData<TimelineEntry[]>(
issueKeys.timeline(issueId),
(old) =>
old?.map((e) =>
e.id === commentId
? { ...e, reactions: [...(e.reactions ?? []), tempReaction] }
: e,
),
);
}
return { prev };
},
onSuccess: (reaction, { commentId }) => {
if (reaction) {
// Replace temp with real
qc.setQueryData<TimelineEntry[]>(
issueKeys.timeline(issueId),
(old) =>
old?.map((e) =>
e.id === commentId
? {
...e,
reactions: (e.reactions ?? []).map((r) =>
r.id.startsWith("temp-") && r.emoji === reaction.emoji
? reaction
: r,
),
}
: e,
),
);
}
},
onError: (_err, _vars, ctx) => {
if (ctx?.prev)
qc.setQueryData(issueKeys.timeline(issueId), ctx.prev);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });
},
@ -429,61 +375,17 @@ export function useToggleCommentReaction(issueId: string) {
export function useToggleIssueReaction(issueId: string) {
const qc = useQueryClient();
return useMutation({
mutationKey: ["toggleIssueReaction", issueId] as const,
mutationFn: async ({
emoji,
existing,
}: {
emoji: string;
existing: IssueReaction | undefined;
}) => {
}: ToggleIssueReactionVars) => {
if (existing) {
await api.removeIssueReaction(issueId, emoji);
return null;
}
return api.addIssueReaction(issueId, emoji);
},
onMutate: async ({ emoji, existing }) => {
await qc.cancelQueries({ queryKey: issueKeys.reactions(issueId) });
const prev = qc.getQueryData<IssueReaction[]>(issueKeys.reactions(issueId));
if (existing) {
qc.setQueryData<IssueReaction[]>(
issueKeys.reactions(issueId),
(old) => old?.filter((r) => r.id !== existing.id),
);
} else {
const temp: IssueReaction = {
id: `temp-${Date.now()}`,
issue_id: issueId,
actor_type: "",
actor_id: "",
emoji,
created_at: new Date().toISOString(),
};
qc.setQueryData<IssueReaction[]>(
issueKeys.reactions(issueId),
(old) => [...(old ?? []), temp],
);
}
return { prev };
},
onSuccess: (reaction) => {
if (reaction) {
qc.setQueryData<IssueReaction[]>(
issueKeys.reactions(issueId),
(old) =>
old?.map((r) =>
r.id.startsWith("temp-") && r.emoji === reaction.emoji
? reaction
: r,
),
);
}
},
onError: (_err, _vars, ctx) => {
if (ctx?.prev)
qc.setQueryData(issueKeys.reactions(issueId), ctx.prev);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: issueKeys.reactions(issueId) });
},

View file

@ -1,19 +1,19 @@
"use client";
import { useCallback } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useMemo } from "react";
import { useQuery, useQueryClient, useMutationState } from "@tanstack/react-query";
import type { IssueReaction } from "@/shared/types";
import type {
IssueReactionAddedPayload,
IssueReactionRemovedPayload,
} from "@/shared/types";
import { issueReactionsOptions, issueKeys } from "@core/issues/queries";
import { useToggleIssueReaction } from "@core/issues/mutations";
import { useToggleIssueReaction, type ToggleIssueReactionVars } from "@core/issues/mutations";
import { useWSEvent, useWSReconnect } from "@/features/realtime";
export function useIssueReactions(issueId: string, userId?: string) {
const qc = useQueryClient();
const { data: reactions = [], isLoading: loading } = useQuery(
const { data: serverReactions = [], isLoading: loading } = useQuery(
issueReactionsOptions(issueId),
);
@ -26,7 +26,7 @@ export function useIssueReactions(issueId: string, userId?: string) {
}, [qc, issueId]),
);
// --- WS event handlers ---
// --- WS event handlers (update server cache for other users' actions) ---
useWSEvent(
"issue_reaction:added",
@ -70,12 +70,60 @@ export function useIssueReactions(issueId: string, userId?: string) {
),
);
// --- Optimistic UI derivation ---
// Instead of writing temp data into the cache (which races with WS events),
// derive optimistic state at render time from pending mutation variables.
const pendingVars = useMutationState({
filters: {
mutationKey: ["toggleIssueReaction", issueId],
status: "pending",
},
select: (m) =>
m.state.variables as ToggleIssueReactionVars | undefined,
});
const reactions = useMemo(() => {
if (pendingVars.length === 0) return serverReactions;
let result = [...serverReactions];
for (const vars of pendingVars) {
if (!vars) continue;
if (vars.existing) {
// Pending removal
result = result.filter((r) => r.id !== vars.existing!.id);
} else {
// Pending add — skip if server already has it (WS arrived first)
const alreadyExists = result.some(
(r) =>
r.emoji === vars.emoji &&
r.actor_type === "member" &&
r.actor_id === userId,
);
if (!alreadyExists) {
result = [
...result,
{
id: `optimistic-${vars.emoji}`,
issue_id: issueId,
actor_type: "member",
actor_id: userId ?? "",
emoji: vars.emoji,
created_at: "",
},
];
}
}
}
return result;
}, [serverReactions, pendingVars, issueId, userId]);
// --- Mutation ---
const toggleReaction = useCallback(
async (emoji: string) => {
if (!userId) return;
const existing = reactions.find(
const existing = serverReactions.find(
(r) =>
r.emoji === emoji &&
r.actor_type === "member" &&
@ -83,7 +131,7 @@ export function useIssueReactions(issueId: string, userId?: string) {
);
toggleMutation.mutate({ emoji, existing });
},
[userId, reactions, toggleMutation],
[userId, serverReactions, toggleMutation],
);
return { reactions, loading, toggleReaction };

View file

@ -1,7 +1,7 @@
"use client";
import { useState, useCallback } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useState, useCallback, useMemo } from "react";
import { useQuery, useQueryClient, useMutationState } from "@tanstack/react-query";
import type { Comment, TimelineEntry, Reaction } from "@/shared/types";
import type {
CommentCreatedPayload,
@ -17,6 +17,7 @@ import {
useUpdateComment,
useDeleteComment,
useToggleCommentReaction,
type ToggleCommentReactionVars,
} from "@core/issues/mutations";
import { useWSEvent, useWSReconnect } from "@/features/realtime";
import { toast } from "sonner";
@ -259,9 +260,65 @@ export function useIssueTimeline(issueId: string, userId?: string) {
[deleteCommentMutation],
);
// --- Optimistic UI derivation for comment reactions ---
// Instead of writing temp data into the cache (which races with WS events),
// derive optimistic state at render time from pending mutation variables.
const pendingReactionVars = useMutationState({
filters: {
mutationKey: ["toggleCommentReaction", issueId],
status: "pending",
},
select: (m) =>
m.state.variables as ToggleCommentReactionVars | undefined,
});
const optimisticTimeline = useMemo(() => {
if (pendingReactionVars.length === 0) return timeline;
return timeline.map((entry) => {
const pendingForEntry = pendingReactionVars.filter(
(v) => v && v.commentId === entry.id,
);
if (pendingForEntry.length === 0) return entry;
let reactions = entry.reactions ?? [];
for (const vars of pendingForEntry) {
if (!vars) continue;
if (vars.existing) {
// Pending removal
reactions = reactions.filter((r) => r.id !== vars.existing!.id);
} else {
// Pending add — skip if server already has it (WS arrived first)
const alreadyExists = reactions.some(
(r) =>
r.emoji === vars.emoji &&
r.actor_type === "member" &&
r.actor_id === userId,
);
if (!alreadyExists) {
reactions = [
...reactions,
{
id: `optimistic-${vars.emoji}`,
comment_id: vars.commentId,
actor_type: "member",
actor_id: userId ?? "",
emoji: vars.emoji,
created_at: "",
},
];
}
}
}
return { ...entry, reactions };
});
}, [timeline, pendingReactionVars, userId]);
const toggleReaction = useCallback(
async (commentId: string, emoji: string) => {
if (!userId) return;
// Read from server timeline (not optimistic) to find the real reaction
const entry = timeline.find((e) => e.id === commentId);
const existing: Reaction | undefined = (entry?.reactions ?? []).find(
(r) =>
@ -275,7 +332,7 @@ export function useIssueTimeline(issueId: string, userId?: string) {
);
return {
timeline,
timeline: optimisticTimeline,
loading,
submitting,
submitComment,