From d28d1f145d9bdaa45fb788aaef69c645719f56bb Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Sat, 16 May 2026 00:40:46 +0200 Subject: [PATCH] fix(native/community): derive heart state from props + store-optimistic delta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the previous mirrored localCount / localLike useState with derived values computed from `post.likesCount` / `post.userLike` plus the existing optimisticLikes entry from the community store. The local-state mirror was the root cause of two separate bugs: 1. Foreign likes never reflected — useState seeded once from props on mount, so the React-Query cache patch in useCommunityRealtime updated the prop but the displayed count stayed frozen at the mount value. 2. The earlier sync-via-useEffect attempt (4c4792c, reverted in ab9472b) broke own-likes because clearing optimistic state could happen before the cache patch landed, so useEffect re-read a stale `post.likesCount` and snapped the count back down — visible as a 2 → 1 → 2 flicker on tap, and as the heart staying red after a toggle-off. The fix is to NOT mirror at all. The store's `optimisticLikes` map already stores `{ delta, userLike }` per post (it was set but never read before). Render path now: displayedLike = optimistic?.userLike ?? (post.userLike === 'like' ? 'like' : null) displayedCount = (post.likesCount ?? 0) + (optimistic?.delta ?? 0) In handleLike, after the API responds, the React-Query cache is patched synchronously with the server-truth response before clearOptimisticLike runs — so the moment the delta drops to 0, the prop already reflects the new count. No race window, no useEffect, no own/foreign distinction needed. `isLiking` is still kept as a re-tap guard against double-tap-mid-flight. --- apps/rebreak-native/components/PostCard.tsx | 52 ++++++++++++++------- 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/apps/rebreak-native/components/PostCard.tsx b/apps/rebreak-native/components/PostCard.tsx index 8b6e6ce..9132139 100644 --- a/apps/rebreak-native/components/PostCard.tsx +++ b/apps/rebreak-native/components/PostCard.tsx @@ -26,8 +26,20 @@ function PostCardImpl({ post, onCommentPress }: Props) { const revertOptimisticLike = useCommunityStore((s) => s.revertOptimisticLike); const clearOptimisticLike = useCommunityStore((s) => s.clearOptimisticLike); - const [localLike, setLocalLike] = useState<'like' | null>(post.userLike === 'like' ? 'like' : null); - const [localCount, setLocalCount] = useState(post.likesCount); + // Display state is DERIVED from props + the store-level optimistic delta. + // No useState mirror — that's exactly what created the foreign-likes-don't- + // update bug (seed-once from props) and the post-action flicker (useEffect + // re-sync racing with the React-Query cache patch). Realtime patches the + // cache → prop changes → we re-render automatically. Optimistic UI lives + // for the ms between tap and API-response in the community-store's + // `optimisticLikes` map (delta + userLike). + const optimistic = useCommunityStore((s) => s.optimisticLikes[post.id]); + const displayedLike: 'like' | null = optimistic + ? optimistic.userLike + : post.userLike === 'like' + ? 'like' + : null; + const displayedCount = (post.likesCount ?? 0) + (optimistic?.delta ?? 0); const [isLiking, setIsLiking] = useState(false); // Heart-Pop Animation — Insta-Style: quick scale-up + spring-bounce back @@ -136,9 +148,7 @@ function PostCardImpl({ post, onCommentPress }: Props) { const handleLike = useCallback(async () => { if (isLiking) return; triggerHeartPop(); - const { newLike, newCount } = applyOptimisticLike(post.id, localLike, localCount); - setLocalLike(newLike); - setLocalCount(newCount); + applyOptimisticLike(post.id, displayedLike, displayedCount); setIsLiking(true); try { const res = await apiFetch<{ @@ -149,19 +159,29 @@ function PostCardImpl({ post, onCommentPress }: Props) { method: 'POST', body: { postId: post.id, type: 'like' }, }); - setLocalCount(res.likesCount); - setLocalLike(res.userLike === 'like' ? 'like' : null); + // Patch the React-Query cache synchronously with server truth so the + // prop reflects it BEFORE we clear the optimistic delta. Without this, + // there'd be a millisecond window between clearOptimistic (delta → 0) + // and the Realtime broadcast patching the cache — during which the + // displayed count would briefly snap back to the old prop value. + queryClient.setQueriesData( + { queryKey: ['community-posts'] }, + (data) => + Array.isArray(data) + ? data.map((p) => + p.id === post.id + ? { ...p, likesCount: res.likesCount, dislikesCount: res.dislikesCount, userLike: res.userLike } + : p, + ) + : data, + ); clearOptimisticLike(post.id); - // KEIN queryClient.invalidateQueries — würde die komplette Liste neu laden, - // PostCard remounted, Heart-Pop-Animation abgebrochen. Local-State reicht. } catch { revertOptimisticLike(post.id); - setLocalLike(post.userLike === 'like' ? 'like' : null); - setLocalCount(post.likesCount); } finally { setIsLiking(false); } - }, [isLiking, localLike, localCount, post.id, post.userLike, post.likesCount, applyOptimisticLike, clearOptimisticLike, revertOptimisticLike, queryClient, triggerHeartPop]); + }, [isLiking, displayedLike, displayedCount, post.id, applyOptimisticLike, clearOptimisticLike, revertOptimisticLike, queryClient, triggerHeartPop]); return ( @@ -278,13 +298,13 @@ function PostCardImpl({ post, onCommentPress }: Props) { > - {localCount > 0 && ( - {localCount} + {displayedCount > 0 && ( + {displayedCount} )}