fix(native/community): derive heart state from props + store-optimistic delta
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.
This commit is contained in:
parent
a735f9a2ab
commit
d28d1f145d
@ -26,8 +26,20 @@ function PostCardImpl({ post, onCommentPress }: Props) {
|
|||||||
const revertOptimisticLike = useCommunityStore((s) => s.revertOptimisticLike);
|
const revertOptimisticLike = useCommunityStore((s) => s.revertOptimisticLike);
|
||||||
const clearOptimisticLike = useCommunityStore((s) => s.clearOptimisticLike);
|
const clearOptimisticLike = useCommunityStore((s) => s.clearOptimisticLike);
|
||||||
|
|
||||||
const [localLike, setLocalLike] = useState<'like' | null>(post.userLike === 'like' ? 'like' : null);
|
// Display state is DERIVED from props + the store-level optimistic delta.
|
||||||
const [localCount, setLocalCount] = useState(post.likesCount);
|
// 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);
|
const [isLiking, setIsLiking] = useState(false);
|
||||||
|
|
||||||
// Heart-Pop Animation — Insta-Style: quick scale-up + spring-bounce back
|
// Heart-Pop Animation — Insta-Style: quick scale-up + spring-bounce back
|
||||||
@ -136,9 +148,7 @@ function PostCardImpl({ post, onCommentPress }: Props) {
|
|||||||
const handleLike = useCallback(async () => {
|
const handleLike = useCallback(async () => {
|
||||||
if (isLiking) return;
|
if (isLiking) return;
|
||||||
triggerHeartPop();
|
triggerHeartPop();
|
||||||
const { newLike, newCount } = applyOptimisticLike(post.id, localLike, localCount);
|
applyOptimisticLike(post.id, displayedLike, displayedCount);
|
||||||
setLocalLike(newLike);
|
|
||||||
setLocalCount(newCount);
|
|
||||||
setIsLiking(true);
|
setIsLiking(true);
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch<{
|
const res = await apiFetch<{
|
||||||
@ -149,19 +159,29 @@ function PostCardImpl({ post, onCommentPress }: Props) {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { postId: post.id, type: 'like' },
|
body: { postId: post.id, type: 'like' },
|
||||||
});
|
});
|
||||||
setLocalCount(res.likesCount);
|
// Patch the React-Query cache synchronously with server truth so the
|
||||||
setLocalLike(res.userLike === 'like' ? 'like' : null);
|
// 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<CommunityPost[]>(
|
||||||
|
{ 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);
|
clearOptimisticLike(post.id);
|
||||||
// KEIN queryClient.invalidateQueries — würde die komplette Liste neu laden,
|
|
||||||
// PostCard remounted, Heart-Pop-Animation abgebrochen. Local-State reicht.
|
|
||||||
} catch {
|
} catch {
|
||||||
revertOptimisticLike(post.id);
|
revertOptimisticLike(post.id);
|
||||||
setLocalLike(post.userLike === 'like' ? 'like' : null);
|
|
||||||
setLocalCount(post.likesCount);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsLiking(false);
|
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 (
|
return (
|
||||||
<View style={{ backgroundColor: colors.bg, borderWidth: 1, borderColor: colors.border, borderRadius: 16, padding: 12, marginBottom: 12 }}>
|
<View style={{ backgroundColor: colors.bg, borderWidth: 1, borderColor: colors.border, borderRadius: 16, padding: 12, marginBottom: 12 }}>
|
||||||
@ -278,13 +298,13 @@ function PostCardImpl({ post, onCommentPress }: Props) {
|
|||||||
>
|
>
|
||||||
<Animated.View style={{ transform: [{ scale: heartScale }] }}>
|
<Animated.View style={{ transform: [{ scale: heartScale }] }}>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name={localLike === 'like' ? 'heart' : 'heart-outline'}
|
name={displayedLike === 'like' ? 'heart' : 'heart-outline'}
|
||||||
size={20}
|
size={20}
|
||||||
color={localLike === 'like' ? '#dc2626' : '#737373'}
|
color={displayedLike === 'like' ? '#dc2626' : '#737373'}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
{localCount > 0 && (
|
{displayedCount > 0 && (
|
||||||
<Text className="text-xs text-neutral-600" style={{ fontFamily: 'Nunito_600SemiBold' }}>{localCount}</Text>
|
<Text className="text-xs text-neutral-600" style={{ fontFamily: 'Nunito_600SemiBold' }}>{displayedCount}</Text>
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user