diff --git a/apps/web/core/issues/mutations.ts b/apps/web/core/issues/mutations.ts index 42575267..85c15166 100644 --- a/apps/web/core/issues/mutations.ts +++ b/apps/web/core/issues/mutations.ts @@ -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(issueKeys.timeline(issueId)); - - if (existing) { - // Remove - qc.setQueryData( - 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( - 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( - 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(issueKeys.reactions(issueId)); - - if (existing) { - qc.setQueryData( - 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( - issueKeys.reactions(issueId), - (old) => [...(old ?? []), temp], - ); - } - return { prev }; - }, - onSuccess: (reaction) => { - if (reaction) { - qc.setQueryData( - 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) }); }, diff --git a/apps/web/features/issues/hooks/use-issue-reactions.ts b/apps/web/features/issues/hooks/use-issue-reactions.ts index 7fec1fd8..6435d77a 100644 --- a/apps/web/features/issues/hooks/use-issue-reactions.ts +++ b/apps/web/features/issues/hooks/use-issue-reactions.ts @@ -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 }; diff --git a/apps/web/features/issues/hooks/use-issue-timeline.ts b/apps/web/features/issues/hooks/use-issue-timeline.ts index 894c2880..c21baa58 100644 --- a/apps/web/features/issues/hooks/use-issue-timeline.ts +++ b/apps/web/features/issues/hooks/use-issue-timeline.ts @@ -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,