diff --git a/apps/web/jest.config.js b/apps/web/jest.config.js index 53f8f875..35b0919a 100644 --- a/apps/web/jest.config.js +++ b/apps/web/jest.config.js @@ -11,6 +11,7 @@ module.exports = { "^linkora-sdk$": "/../../packages/sdk/src/index.ts", "^@linkora/types/src/(.*)$": "/../../packages/types/src/$1", "^@linkora/types$": "/../../packages/types/src/index.ts", + "^(\\.\\.?/.*)\\.js$": "$1", }, transform: { "^.+\\.(ts|tsx)$": [ diff --git a/apps/web/jest.setup.ts b/apps/web/jest.setup.ts index 3ab202fb..373b36a6 100644 --- a/apps/web/jest.setup.ts +++ b/apps/web/jest.setup.ts @@ -1,10 +1,10 @@ // Jest setup file for DOM and React testing utilities import "@testing-library/jest-dom"; import { toHaveNoViolations } from "jest-axe"; -import { TextEncoder } from "util"; +import { TextEncoder, TextDecoder } from "util"; -// Polyfill TextEncoder for jsdom (needed by SDK utf8 helper) -global.TextEncoder = TextEncoder; +(globalThis as any).TextEncoder = (globalThis as any).TextEncoder || TextEncoder; +(globalThis as any).TextDecoder = (globalThis as any).TextDecoder || TextDecoder; // Extend Jest matchers with jest-axe expect.extend(toHaveNoViolations); diff --git a/apps/web/src/app/feed/page.tsx b/apps/web/src/app/feed/page.tsx index 4ffb4d98..96f89cf4 100644 --- a/apps/web/src/app/feed/page.tsx +++ b/apps/web/src/app/feed/page.tsx @@ -193,6 +193,9 @@ export default function FeedPage() { const newPosts = append ? [...posts, ...fetchedPosts] : fetchedPosts; persistFeed({ posts: newPosts, cursor: cursorParam, hasMore: data.has_more ?? false }); + + // Reconcile optimistic state against the fresh server response (#1203) + OptimisticStore.reconcileFeed(currentUserAddress, newPosts); } catch (err) { setError(err instanceof Error ? err.message : "An error occurred"); } finally { @@ -200,7 +203,7 @@ export default function FeedPage() { setLoadingMore(false); } }, - [posts, persistFeed] + [posts, persistFeed, currentUserAddress] ); const fetchFollowingFeed = useCallback( @@ -229,6 +232,8 @@ export default function FeedPage() { setFollowsNobody(true); setLoading(false); setLoadingMore(false); + // No visible posts — prune all optimistic entries (#1203) + OptimisticStore.reconcileFeed(currentUserAddress, []); return; } setFollowsNobody(false); @@ -265,6 +270,9 @@ export default function FeedPage() { cursor: cursorParam, hasMore: startIdx + paginated.length < allFetchedPosts.length, }); + + // Reconcile optimistic state against the fresh server response (#1203) + OptimisticStore.reconcileFeed(currentUserAddress, newPosts); } catch (err) { setError(err instanceof Error ? err.message : "An error occurred"); } finally { diff --git a/apps/web/src/lib/optimisticStore.test.ts b/apps/web/src/lib/optimisticStore.test.ts new file mode 100644 index 00000000..580c8dc7 --- /dev/null +++ b/apps/web/src/lib/optimisticStore.test.ts @@ -0,0 +1,95 @@ +import { OptimisticStore } from "./optimisticStore"; + +/** + * Unit tests for OptimisticStore.reconcileFeed — issue #1203. + * + * The reconcileFeed method prunes optimistic like/tip state that is stale + * after a feed refetch (e.g. when the active filter changes). + */ +describe("OptimisticStore.reconcileFeed (#1203)", () => { + afterEach(() => { + // Clean up any leftover state between tests. + // We call reconcileFeed with no posts to clear like entries, and + // manually clear remaining entries via the public API. + OptimisticStore.reconcileFeed("__cleanup__", []); + }); + + it("prunes an optimistic like entry when the post is filtered out of the visible set", () => { + const user = "GUSER1"; + const key = `${user}:42`; + + OptimisticStore.setLikeState(key, { isLiked: true, likeCount: 5 }); + expect(OptimisticStore.getLikeState(key)).toEqual({ isLiked: true, likeCount: 5 }); + + // Refetch returns posts that do NOT include post 42 + OptimisticStore.reconcileFeed(user, [{ id: 10 }, { id: 20 }]); + + expect(OptimisticStore.getLikeState(key)).toBeUndefined(); + }); + + it("drops the optimistic like entry for a server-confirmed post (server wins via initialState fallback)", () => { + const user = "GUSER2"; + const key = `${user}:7`; + + OptimisticStore.setLikeState(key, { isLiked: true, likeCount: 10 }); + expect(OptimisticStore.getLikeState(key)).toBeDefined(); + + // Refetch returns post 7 with server-confirmed data — + // the optimistic entry is deleted so the component falls back to + // initialState which reflects the server truth. + OptimisticStore.reconcileFeed(user, [{ id: 7 }, { id: 8 }]); + + expect(OptimisticStore.getLikeState(key)).toBeUndefined(); + }); + + it("prunes optimistic tip state for a post absent from the visible set", () => { + OptimisticStore.setTipState("99", { tipTotal: 500 }); + expect(OptimisticStore.getTipState("99")).toEqual({ tipTotal: 500 }); + + OptimisticStore.reconcileFeed("GUSER3", [{ id: 1 }]); + + expect(OptimisticStore.getTipState("99")).toBeUndefined(); + }); + + it("prunes optimistic tip state for a post present in the visible set (server wins)", () => { + OptimisticStore.setTipState("5", { tipTotal: 100 }); + expect(OptimisticStore.getTipState("5")).toBeDefined(); + + OptimisticStore.reconcileFeed("GUSER4", [{ id: 5 }]); + + expect(OptimisticStore.getTipState("5")).toBeUndefined(); + }); + + it("does NOT prune optimistic like entries belonging to a different user", () => { + const otherUser = "GOTHER"; + const currentUser = "GCURRENT"; + const otherKey = `${otherUser}:42`; + + OptimisticStore.setLikeState(otherKey, { isLiked: true, likeCount: 3 }); + + // Reconcile as currentUser — otherUser's entry should survive + OptimisticStore.reconcileFeed(currentUser, [{ id: 42 }]); + + expect(OptimisticStore.getLikeState(otherKey)).toEqual({ isLiked: true, likeCount: 3 }); + + // Cleanup + OptimisticStore.reconcileFeed(otherUser, []); + }); + + it("notifies subscribers when entries are pruned", () => { + const listener = jest.fn(); + const unsubscribe = OptimisticStore.subscribe(listener); + + const user = "GNOTIFY"; + OptimisticStore.setLikeState(`${user}:1`, { isLiked: true, likeCount: 1 }); + + // Reset the call count after the setLikeState notification + listener.mockClear(); + + OptimisticStore.reconcileFeed(user, []); + + expect(listener).toHaveBeenCalledTimes(1); + + unsubscribe(); + }); +}); diff --git a/apps/web/src/lib/optimisticStore.ts b/apps/web/src/lib/optimisticStore.ts index b9bac6e1..25415f02 100644 --- a/apps/web/src/lib/optimisticStore.ts +++ b/apps/web/src/lib/optimisticStore.ts @@ -86,6 +86,62 @@ export const OptimisticStore = { return tipStateMap.get(key); }, + clearLikeState(key: string) { + likeStateMap.delete(key); + notify(); + }, + + clearTipState(key: string) { + tipStateMap.delete(key); + notify(); + }, + + /** + * Reconcile optimistic state against a fresh server response. + * + * Resolution rules: + * 1. For each likeStateMap entry whose postId IS in visiblePosts, + * delete it — the component will fall back to `initialState` which + * reflects the server-confirmed value (server wins). + * 2. For each likeStateMap entry whose postId is NOT in visiblePosts, + * delete it — the post has been filtered out and the optimistic + * entry should not persist. + * 3. Entries belonging to a different userAddress are left untouched. + * 4. Same rules apply for tipStateMap (keyed by postId string). + */ + reconcileFeed(userAddress: string | null, visiblePosts: Array<{ id: string | number }>) { + const visibleIds = new Set(visiblePosts.map((p) => String(p.id))); + let changed = false; + + // Reconcile like state — keys are `${userAddress}:${postId}` + if (userAddress) { + for (const key of [...likeStateMap.keys()]) { + const separatorIdx = key.indexOf(":"); + if (separatorIdx === -1) continue; + + const keyUser = key.slice(0, separatorIdx); + if (keyUser !== userAddress) continue; + + // Post is either present (server wins) or absent (filtered out) — either way, drop it + likeStateMap.delete(key); + changed = true; + } + } + + // Reconcile tip state — keys are postId strings + for (const key of [...tipStateMap.keys()]) { + // Only prune entries for posts we have a definitive answer about. + // If the post is in the visible set, server truth is now available + // via initialState. If absent, the optimistic entry is stale. + tipStateMap.delete(key); + changed = true; + } + + if (changed) { + notify(); + } + }, + // Legacy API for FollowList.tsx subscribe, isFollowing(targetAddress: string): boolean { @@ -152,10 +208,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( diff --git a/packages/sdk/src/__tests__/events-drift.test.ts b/packages/sdk/src/__tests__/events-drift.test.ts index a8e092a1..6b96b363 100644 --- a/packages/sdk/src/__tests__/events-drift.test.ts +++ b/packages/sdk/src/__tests__/events-drift.test.ts @@ -4,11 +4,7 @@ import { parseRawContractEvent as _parseRawContractEvent } from "../generated/ev describe("Event type drift", () => { it("dummy runtime check to satisfy jest", () => { - // We would ideally want to dynamically check that `parseContractEvent` supports every event. - // However, TypeScript doesn't let us iterate over union variants at runtime. - // Instead, the fact that `events/types.ts` now uses `Omit` ensures - // that the fields themselves cannot drift. - // This is the core fix to the "Duplicate/XOR'd kept events.ts types vs generated/events.ts" issue. - expect(true).toBe(true); + expect(typeof parseContractEvent).toBe("function"); + expect(typeof parseRawContractEvent).toBe("function"); }); }); diff --git a/packages/sdk/src/__tests__/write.test.ts b/packages/sdk/src/__tests__/write.test.ts index 2641400a..28125b86 100644 --- a/packages/sdk/src/__tests__/write.test.ts +++ b/packages/sdk/src/__tests__/write.test.ts @@ -306,4 +306,80 @@ describe("prepare*Tx methods (Submittable)", () => { addr("GB") ); }); + + it("prepareUnfollowTx fetches sequence and uses prepareTransaction", async () => { + jest + .spyOn(client as unknown as { getAccountForTx: jest.Mock }, "getAccountForTx") + .mockResolvedValue({ _accountId: "GA", sequence: "100" }); + jest.spyOn(client, "prepareTransaction").mockResolvedValue({ + toEnvelope: () => ({ toXDR: () => "PREPARED_XDR" }), + } as unknown as ReturnType); + + const result = await client.prepareUnfollowTx("GA", "GB"); + expect(result).toBe("PREPARED_XDR"); + expect(client.prepareTransaction).toHaveBeenCalledWith( + "unfollow", + expect.objectContaining({ _accountId: "GA" }), + addr("GA"), + addr("GB") + ); + }); + + it("prepareLikePostTx fetches sequence and encodes postId as u64", async () => { + jest + .spyOn(client as unknown as { getAccountForTx: jest.Mock }, "getAccountForTx") + .mockResolvedValue({ _accountId: "GUSER", sequence: "100" }); + jest.spyOn(client, "prepareTransaction").mockResolvedValue({ + toEnvelope: () => ({ toXDR: () => "PREPARED_XDR" }), + } as unknown as ReturnType); + + const result = await client.prepareLikePostTx("GUSER", 42); + expect(result).toBe("PREPARED_XDR"); + expect(client.prepareTransaction).toHaveBeenCalledWith( + "like_post", + expect.objectContaining({ _accountId: "GUSER" }), + addr("GUSER"), + val(42) + ); + }); + + it("prepareTipTx fetches sequence and encodes arguments correctly", async () => { + jest + .spyOn(client as unknown as { getAccountForTx: jest.Mock }, "getAccountForTx") + .mockResolvedValue({ _accountId: "GTIPPER", sequence: "100" }); + jest.spyOn(client, "prepareTransaction").mockResolvedValue({ + toEnvelope: () => ({ toXDR: () => "PREPARED_XDR" }), + } as unknown as ReturnType); + + const result = await client.prepareTipTx("GTIPPER", 42, "GTOKEN", 1000n); + expect(result).toBe("PREPARED_XDR"); + expect(client.prepareTransaction).toHaveBeenCalledWith( + "tip", + expect.objectContaining({ _accountId: "GTIPPER" }), + addr("GTIPPER"), + val(42), + addr("GTOKEN"), + val(1000n) + ); + }); + + it("preparePoolDepositTx fetches sequence and encodes poolId as symbol", async () => { + jest + .spyOn(client as unknown as { getAccountForTx: jest.Mock }, "getAccountForTx") + .mockResolvedValue({ _accountId: "GDEPOSITOR", sequence: "100" }); + jest.spyOn(client, "prepareTransaction").mockResolvedValue({ + toEnvelope: () => ({ toXDR: () => "PREPARED_XDR" }), + } as unknown as ReturnType); + + const result = await client.preparePoolDepositTx("GDEPOSITOR", "pool1", "GTOKEN", 500n); + expect(result).toBe("PREPARED_XDR"); + expect(client.prepareTransaction).toHaveBeenCalledWith( + "pool_deposit", + expect.objectContaining({ _accountId: "GDEPOSITOR" }), + addr("GDEPOSITOR"), + val("pool1"), + addr("GTOKEN"), + val(500n) + ); + }); }); diff --git a/packages/sdk/src/events/types.ts b/packages/sdk/src/events/types.ts index fae6f210..b57f0c48 100644 --- a/packages/sdk/src/events/types.ts +++ b/packages/sdk/src/events/types.ts @@ -598,7 +598,7 @@ export function parseContractEvent(raw: SorobanEvent): LinkoraEvent | null { case "post_reported": return { type: eventType, - post_id: big(payload.post_id), + post_id: num(payload.post_id), reporter: str(payload.reporter), stake_amount: big(payload.stake_amount), meta: eventMeta, @@ -606,14 +606,14 @@ export function parseContractEvent(raw: SorobanEvent): LinkoraEvent | null { case "post_removed_by_moderation": return { type: eventType, - post_id: big(payload.post_id), + post_id: num(payload.post_id), reporter: str(payload.reporter), meta: eventMeta, }; case "report_dismissed": return { type: eventType, - post_id: big(payload.post_id), + post_id: num(payload.post_id), reporter: str(payload.reporter), meta: eventMeta, };