From f3015c407e12fc219908451db6648c764f4d14fc Mon Sep 17 00:00:00 2001 From: olajcodes Date: Sat, 29 Aug 2026 05:07:24 +0100 Subject: [PATCH] fix(web): rollback optimistic feed state on failed transaction Capture a deep snapshot of like/follow/tip state before each optimistic mutation in optimizerStore and restore it when the on-chain transaction reports failed, emitting a rolled-back event so the Feed can clear its transient pending styling. Add unit tests covering success, failure, and partial rollback ordering. --- apps/web/src/components/Feed.tsx | 58 ++++- .../src/lib/__tests__/optimisticStore.test.ts | 240 ++++++++++++++++++ apps/web/src/lib/optimisticStore.ts | 127 ++++++++- 3 files changed, 415 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/lib/__tests__/optimisticStore.test.ts diff --git a/apps/web/src/components/Feed.tsx b/apps/web/src/components/Feed.tsx index 1d228c81..b58c2752 100644 --- a/apps/web/src/components/Feed.tsx +++ b/apps/web/src/components/Feed.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import { PostCard, Post } from "./PostCard"; import { fetchIsPaused } from "../lib/api"; +import { OptimisticStore } from "../lib/optimisticStore"; /** How often to re-check the contract's pause status while the feed is mounted. */ const PAUSE_POLL_INTERVAL_MS = 30_000; @@ -20,6 +21,11 @@ export function Feed({ posts, loading, onLike, onTip, likedPosts = new Set() }: // without requiring a page reload. const [paused, setPaused] = useState(false); + // Post ids with an optimistic like/follow transaction currently in flight. + // Used to show transient "pending" styling that is cleared on rollback or + // once the optimistic write settles. + const [pendingLikes, setPendingLikes] = useState>(new Set()); + useEffect(() => { let cancelled = false; @@ -36,13 +42,40 @@ export function Feed({ posts, loading, onLike, onTip, likedPosts = new Set() }: }; }, []); + // Clear transient "pending" styling for the affected post when an optimistic + // like/follow write is rolled back after a failed transaction. + useEffect(() => { + const unsubscribe = OptimisticStore.onRolledBack((event) => { + if (event.kind !== "like" && event.kind !== "follow") return; + // Like/follow keys are `${userAddress}:${postId}`. + const postId = Number(event.key.split(":").pop()); + if (Number.isNaN(postId)) return; + setPendingLikes((prev) => { + if (!prev.has(postId)) return prev; + const next = new Set(prev); + next.delete(postId); + return next; + }); + }); + return unsubscribe; + }, []); + + const beginOptimisticWrite = (postId: number) => { + setPendingLikes((prev) => { + const next = new Set(prev); + next.add(postId); + return next; + }); + }; + // Re-check immediately before submitting a write, to catch the contract // being paused between polls (race condition), and only proceed if clear. - const guardedWrite = async (action: () => void) => { + const guardedWrite = async (action: (postId: number) => void, postId: number) => { const isPaused = await fetchIsPaused(); setPaused(isPaused); if (isPaused) return; - action(); + beginOptimisticWrite(postId); + action(postId); }; if (loading) { @@ -80,11 +113,19 @@ export function Feed({ posts, loading, onLike, onTip, likedPosts = new Set() }: {onLike && ( )} {onTip && ( @@ -92,7 +133,7 @@ export function Feed({ posts, loading, onLike, onTip, likedPosts = new Set() }: type="button" style={{ ...styles.actionButton, ...(paused ? styles.actionButtonDisabled : {}) }} disabled={paused} - onClick={() => guardedWrite(() => onTip(Number(post.id)))} + onClick={() => guardedWrite(() => onTip(Number(post.id)), Number(post.id))} > Tip @@ -148,6 +189,11 @@ const styles: Record = { opacity: 0.5, cursor: "not-allowed", }, + actionButtonPending: { + opacity: 0.7, + cursor: "progress", + animation: "pulse 1.5s ease-in-out infinite", + }, empty: { textAlign: "center", padding: "var(--spacing-xl)", diff --git a/apps/web/src/lib/__tests__/optimisticStore.test.ts b/apps/web/src/lib/__tests__/optimisticStore.test.ts new file mode 100644 index 00000000..a691d7c7 --- /dev/null +++ b/apps/web/src/lib/__tests__/optimisticStore.test.ts @@ -0,0 +1,240 @@ +/** + * optimisticStore.test.ts + * + * Unit tests for the optimistic feed store's snapshot / rollback behaviour. + * + * The store applies optimistic like/follow/tip mutations immediately so the UI + * feels instant, but if the underlying transaction reports `failed` the + * optimistic state must be reverted to the pre-mutation snapshot. These tests + * cover: + * 1. Success path — committed optimistic state is kept. + * 2. Failure path — a failed write restores the pre-mutation snapshot and + * emits a `rolled-back` event so pending styling can be cleared. + * 3. Partial rollback ordering — rolling back one post/user does not touch + * unrelated snapshots, and sequenced rollbacks restore their own state. + */ + +import { OptimisticStore, type RolledBackEvent } from "@/lib/optimisticStore"; + +function clearStore() { + // No public reset API — rebuild by clearing every key used across the test + // suites. The store is module-level, so each test must start from a clean + // slate regardless of which describe block ran before it. + for (const key of ["GA:1", "GA:2", "u:1", "u:2", "u:3"]) { + OptimisticStore.clearLikeState(key); + } + OptimisticStore.clearFollowState("alice:bob"); + OptimisticStore.clearFollowState("alice:carol"); + OptimisticStore.clearTipState("p1"); + OptimisticStore.clearTipState("p2"); +} + +beforeEach(() => { + clearStore(); +}); + +describe("like rollback", () => { + const key = "GA:1"; + + it("keeps the committed optimistic like on success", () => { + OptimisticStore.setLikeState(key, { isLiked: false, likeCount: 10 }); + + // Begin an optimistic write: snapshot the pre-mutation state. + const previous = OptimisticStore.getLikeState(key); + expect(previous).toEqual({ isLiked: false, likeCount: 10 }); + + OptimisticStore.snapshotLikeState(key); + OptimisticStore.setLikeState(key, { isLiked: true, likeCount: 11 }); + + // Success path: no rollback is triggered, committed state is retained. + expect(OptimisticStore.getLikeState(key)).toEqual({ isLiked: true, likeCount: 11 }); + }); + + it("restores the pre-mutation snapshot and emits a rolled-back event on failure", () => { + const rolledBack: RolledBackEvent[] = []; + const unsubscribe = OptimisticStore.onRolledBack((e) => rolledBack.push(e)); + + OptimisticStore.setLikeState(key, { isLiked: false, likeCount: 10 }); + OptimisticStore.snapshotLikeState(key); + OptimisticStore.setLikeState(key, { isLiked: true, likeCount: 11 }); + + // Simulate the transaction reporting `failed`. + const didRollback = OptimisticStore.rollbackLikeState(key); + + expect(didRollback).toBe(true); + expect(OptimisticStore.getLikeState(key)).toEqual({ isLiked: false, likeCount: 10 }); + expect(rolledBack).toEqual([{ kind: "like", key }]); + + // Snapshot is consumed, so a second rollback is a no-op. + expect(OptimisticStore.rollbackLikeState(key)).toBe(false); + + unsubscribe(); + }); + + it("removes the optimistic entry when there was no prior snapshot state", () => { + const rolledBack: RolledBackEvent[] = []; + const unsubscribe = OptimisticStore.onRolledBack((e) => rolledBack.push(e)); + + // No optimistic state exists yet for this key; the transaction fails. + OptimisticStore.snapshotLikeState(key); + OptimisticStore.setLikeState(key, { isLiked: true, likeCount: 1 }); + + OptimisticStore.rollbackLikeState(key); + + // Undefined prior state means the optimistic entry is dropped so the hook + // falls back to server truth. + expect(OptimisticStore.getLikeState(key)).toBeUndefined(); + expect(rolledBack).toEqual([{ kind: "like", key }]); + + unsubscribe(); + }); +}); + +describe("follow rollback", () => { + it("restores the pre-mutation follow snapshot and emits a rolled-back event", () => { + const key = "alice:bob"; + const rolledBack: RolledBackEvent[] = []; + const unsubscribe = OptimisticStore.onRolledBack((e) => rolledBack.push(e)); + + OptimisticStore.setFollowState(key, { + isFollowing: false, + followersCount: 3, + followingCount: 5, + }); + OptimisticStore.snapshotFollowState(key); + OptimisticStore.setFollowState(key, { + isFollowing: true, + followersCount: 4, + followingCount: 5, + }); + + OptimisticStore.rollbackFollowState(key); + + expect(OptimisticStore.getFollowState(key)).toEqual({ + isFollowing: false, + followersCount: 3, + followingCount: 5, + }); + expect(rolledBack).toEqual([{ kind: "follow", key }]); + + unsubscribe(); + }); + + it("keeps the committed follow on success", () => { + const key = "alice:carol"; + OptimisticStore.setFollowState(key, { + isFollowing: false, + followersCount: 1, + followingCount: 0, + }); + OptimisticStore.snapshotFollowState(key); + OptimisticStore.setFollowState(key, { + isFollowing: true, + followersCount: 2, + followingCount: 0, + }); + + // Success: no rollback. + expect(OptimisticStore.getFollowState(key)).toEqual({ + isFollowing: true, + followersCount: 2, + followingCount: 0, + }); + }); +}); + +describe("tip rollback", () => { + it("restores the pre-mutation tip total and emits a rolled-back event", () => { + const key = "p1"; + const rolledBack: RolledBackEvent[] = []; + const unsubscribe = OptimisticStore.onRolledBack((e) => rolledBack.push(e)); + + OptimisticStore.setTipState(key, { tipTotal: 100 }); + OptimisticStore.snapshotTipState(key); + OptimisticStore.setTipState(key, { tipTotal: 150 }); + + OptimisticStore.rollbackTipState(key); + + expect(OptimisticStore.getTipState(key)).toEqual({ tipTotal: 100 }); + expect(rolledBack).toEqual([{ kind: "tip", key }]); + + unsubscribe(); + }); +}); + +describe("partial rollback ordering", () => { + it("rolls back the exact post/user without disturbing unrelated snapshots", () => { + const keyA = "u:1"; + const keyB = "u:2"; + const events: RolledBackEvent[] = []; + const unsubscribe = OptimisticStore.onRolledBack((e) => events.push(e)); + + // Two posts with independent optimistic writes. + OptimisticStore.setLikeState(keyA, { isLiked: false, likeCount: 1 }); + OptimisticStore.setLikeState(keyB, { isLiked: false, likeCount: 2 }); + + OptimisticStore.snapshotLikeState(keyA); + OptimisticStore.snapshotLikeState(keyB); + + OptimisticStore.setLikeState(keyA, { isLiked: true, likeCount: 2 }); + OptimisticStore.setLikeState(keyB, { isLiked: true, likeCount: 3 }); + + // Post A fails — only A is rolled back; B keeps its optimistic state. + OptimisticStore.rollbackLikeState(keyA); + + expect(OptimisticStore.getLikeState(keyA)).toEqual({ isLiked: false, likeCount: 1 }); + expect(OptimisticStore.getLikeState(keyB)).toEqual({ isLiked: true, likeCount: 3 }); + expect(events).toEqual([{ kind: "like", key: keyA }]); + + // Post B also fails later — independently rolled back to its own snapshot. + OptimisticStore.rollbackLikeState(keyB); + expect(OptimisticStore.getLikeState(keyB)).toEqual({ isLiked: false, likeCount: 2 }); + expect(events).toEqual([ + { kind: "like", key: keyA }, + { kind: "like", key: keyB }, + ]); + + unsubscribe(); + }); +}); + +describe("rollback snapshots are deep clones", () => { + it("does not share references so mutating live state cannot corrupt memory of the snapshot", () => { + const key = "GA:2"; + const previous: { isLiked: boolean; likeCount: number } = { isLiked: false, likeCount: 5 }; + + OptimisticStore.setLikeState(key, previous); + OptimisticStore.snapshotLikeState(key); + + // Mutate the original object after snapshotting — the snapshot must not be + // affected because it is a deep clone. + previous.likeCount = 999; + + OptimisticStore.setLikeState(key, { isLiked: true, likeCount: 6 }); + OptimisticStore.rollbackLikeState(key); + + expect(OptimisticStore.getLikeState(key)).toEqual({ isLiked: false, likeCount: 5 }); + }); +}); + +describe("onRolledBack", () => { + it("stops delivering events after unsubscribing", () => { + const key = "u:3"; + const events: RolledBackEvent[] = []; + const unsubscribe = OptimisticStore.onRolledBack((e) => events.push(e)); + + OptimisticStore.setLikeState(key, { isLiked: false, likeCount: 0 }); + OptimisticStore.snapshotLikeState(key); + OptimisticStore.setLikeState(key, { isLiked: true, likeCount: 1 }); + OptimisticStore.rollbackLikeState(key); + expect(events).toHaveLength(1); + + unsubscribe(); + + OptimisticStore.setLikeState(key, { isLiked: false, likeCount: 0 }); + OptimisticStore.snapshotLikeState(key); + OptimisticStore.setLikeState(key, { isLiked: true, likeCount: 1 }); + OptimisticStore.rollbackLikeState(key); + expect(events).toHaveLength(1); + }); +}); diff --git a/apps/web/src/lib/optimisticStore.ts b/apps/web/src/lib/optimisticStore.ts index b9bac6e1..5d1776b2 100644 --- a/apps/web/src/lib/optimisticStore.ts +++ b/apps/web/src/lib/optimisticStore.ts @@ -21,6 +21,25 @@ export type TipState = { tipTotal: number; }; +/* ────────────────────────────────────────────────────────────────────────── */ +/* Rollback events */ +/* ────────────────────────────────────────────────────────────────────────── */ + +export type RolledBackEvent = { + kind: "follow" | "like" | "tip"; + key: string; +}; + +export type RolledBackListener = (event: RolledBackEvent) => void; + +// Returns a deep clone so snapshots never share references with live state. +// The optimistic state objects are plain JSON-serializable data (numbers and +// booleans), so a structured clone is a safe deep copy and works in every +// environment (Node, jsdom, browsers) without relying on structuredClone. +function deepClone(value: T): T { + return value === undefined ? value : (JSON.parse(JSON.stringify(value)) as T); +} + /* ────────────────────────────────────────────────────────────────────────── */ /* Store internals */ /* ────────────────────────────────────────────────────────────────────────── */ @@ -33,6 +52,15 @@ const likeStateMap = new Map(); const tipStateMap = new Map(); const listeners = new Set<() => void>(); +// Snapshots capture the pre-mutation state so a failed on-chain transaction +// can restore the exact UI the user saw before optimistically applying the +// mutation. They are keyed per state kind + key so rollback is scoped to the +// exact post/user affected. +const followSnapshots = new Map(); +const likeSnapshots = new Map(); +const tipSnapshots = new Map(); +const rolledBackListeners = new Set(); + function subscribe(listener: () => void) { listeners.add(listener); return () => { @@ -46,6 +74,21 @@ function notify() { } } +function emitRolledBack(event: RolledBackEvent) { + for (const listener of rolledBackListeners) { + listener(event); + } + // Re-render any subscribed components so they observe the restored state. + notify(); +} + +function onRolledBack(listener: RolledBackListener) { + rolledBackListeners.add(listener); + return () => { + rolledBackListeners.delete(listener); + }; +} + /* ────────────────────────────────────────────────────────────────────────── */ /* Public API */ /* ────────────────────────────────────────────────────────────────────────── */ @@ -65,9 +108,32 @@ export const OptimisticStore = { clearFollowState(key: string) { followStateMap.delete(key); + followSnapshots.delete(key); notify(); }, + // Capture the pre-mutation follow state so it can be restored on failure. + snapshotFollowState(key: string) { + followSnapshots.set(key, deepClone(followStateMap.get(key))); + }, + + // Restore the pre-mutation follow state and clear transient pending styling. + rollbackFollowState(key: string) { + if (!followSnapshots.has(key)) { + followSnapshots.delete(key); + return false; + } + const snapshot = followSnapshots.get(key); + if (snapshot === undefined) { + followStateMap.delete(key); + } else { + followStateMap.set(key, deepClone(snapshot)); + } + followSnapshots.delete(key); + emitRolledBack({ kind: "follow", key }); + return true; + }, + setLikeState(key: string, state: LikeState) { likeStateMap.set(key, state); notify(); @@ -77,6 +143,32 @@ export const OptimisticStore = { return likeStateMap.get(key); }, + clearLikeState(key: string) { + likeStateMap.delete(key); + likeSnapshots.delete(key); + notify(); + }, + + snapshotLikeState(key: string) { + likeSnapshots.set(key, deepClone(likeStateMap.get(key))); + }, + + rollbackLikeState(key: string) { + if (!likeSnapshots.has(key)) { + likeSnapshots.delete(key); + return false; + } + const snapshot = likeSnapshots.get(key); + if (snapshot === undefined) { + likeStateMap.delete(key); + } else { + likeStateMap.set(key, deepClone(snapshot)); + } + likeSnapshots.delete(key); + emitRolledBack({ kind: "like", key }); + return true; + }, + setTipState(key: string, state: TipState) { tipStateMap.set(key, state); notify(); @@ -86,6 +178,36 @@ export const OptimisticStore = { return tipStateMap.get(key); }, + clearTipState(key: string) { + tipStateMap.delete(key); + tipSnapshots.delete(key); + notify(); + }, + + snapshotTipState(key: string) { + tipSnapshots.set(key, deepClone(tipStateMap.get(key))); + }, + + rollbackTipState(key: string) { + if (!tipSnapshots.has(key)) { + tipSnapshots.delete(key); + return false; + } + const snapshot = tipSnapshots.get(key); + if (snapshot === undefined) { + tipStateMap.delete(key); + } else { + tipStateMap.set(key, deepClone(snapshot)); + } + tipSnapshots.delete(key); + emitRolledBack({ kind: "tip", key }); + return true; + }, + + // Subscribe to rollback events so components can clear transient "pending" + // styling for the affected feed slice. Returns an unsubscribe function. + onRolledBack, + // Legacy API for FollowList.tsx subscribe, isFollowing(targetAddress: string): boolean { @@ -152,10 +274,7 @@ export function useOptimisticLike( * Returns the optimistic tip state if one exists, otherwise falls back * to `initialState`. */ -export function useOptimisticTip( - postId: string | bigint, - initialState: TipState -): TipState { +export function useOptimisticTip(postId: string | bigint, initialState: TipState): TipState { const key = String(postId); const optimistic = useSyncExternalStore(