Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ module.exports = {
"^linkora-sdk$": "<rootDir>/../../packages/sdk/src/index.ts",
"^@linkora/types/src/(.*)$": "<rootDir>/../../packages/types/src/$1",
"^@linkora/types$": "<rootDir>/../../packages/types/src/index.ts",
"^(\\.\\.?/.*)\\.js$": "$1",
},
transform: {
"^.+\\.(ts|tsx)$": [
Expand Down
6 changes: 3 additions & 3 deletions apps/web/jest.setup.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down
10 changes: 9 additions & 1 deletion apps/web/src/app/feed/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -193,14 +193,17 @@ 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 {
setLoading(false);
setLoadingMore(false);
}
},
[posts, persistFeed]
[posts, persistFeed, currentUserAddress]
);

const fetchFollowingFeed = useCallback(
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down
95 changes: 95 additions & 0 deletions apps/web/src/lib/optimisticStore.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
61 changes: 57 additions & 4 deletions apps/web/src/lib/optimisticStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
Expand Down
8 changes: 2 additions & 6 deletions packages/sdk/src/__tests__/events-drift.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Gen.XxxEvent, "type">` 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");
});
});
76 changes: 76 additions & 0 deletions packages/sdk/src/__tests__/write.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof client.prepareTransaction>);

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<typeof client.prepareTransaction>);

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<typeof client.prepareTransaction>);

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<typeof client.prepareTransaction>);

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)
);
});
});
6 changes: 3 additions & 3 deletions packages/sdk/src/events/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -598,22 +598,22 @@ 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,
};
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,
};
Expand Down
Loading