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:
commit
0dcaa60919
3 changed files with 135 additions and 128 deletions
|
|
@ -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) });
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue