From 2a64ce049286ab4769eefac26549a3a04b11e142 Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Sun, 7 Jun 2026 11:30:23 +0700 Subject: [PATCH 1/3] fix(comments): continue sparse comment updates before stopping --- src/stores/comments/comments-store.test.ts | 186 ++++++++++++++++++++- src/stores/comments/comments-store.ts | 51 +++++- 2 files changed, 233 insertions(+), 4 deletions(-) diff --git a/src/stores/comments/comments-store.test.ts b/src/stores/comments/comments-store.test.ts index 911577af..f8841803 100644 --- a/src/stores/comments/comments-store.test.ts +++ b/src/stores/comments/comments-store.test.ts @@ -1,4 +1,4 @@ -import { act } from "@testing-library/react"; +import { act, waitFor as tlWaitFor } from "@testing-library/react"; import EventEmitter from "events"; import testUtils, { renderHook } from "../../lib/test-utils"; import commentsStore, { @@ -16,6 +16,55 @@ import repliesPagesStore from "../replies-pages"; let mockAccount: any; let accountsGetState: typeof accountsStore.getState; +const createSparseComment = (commentCid: string, options: { holdMutableUpdate?: boolean } = {}) => { + const liveComment: any = new EventEmitter(); + liveComment.cid = commentCid; + liveComment.clients = {}; + liveComment.off = liveComment.off.bind(liveComment); + liveComment.removeAllListeners = liveComment.removeAllListeners.bind(liveComment); + liveComment.once = liveComment.once.bind(liveComment); + liveComment.stop = vi.fn().mockImplementation(() => { + liveComment.updatingState = "stopped"; + return Promise.resolve(); + }); + liveComment.update = vi.fn().mockImplementation(() => { + liveComment.updatingState = "fetching-ipfs"; + liveComment.emit("updatingstatechange", "fetching-ipfs"); + + if (liveComment.update.mock.calls.length === 1) { + liveComment.content = "sparse body"; + liveComment.timestamp = 100; + liveComment.updatingState = "succeeded"; + liveComment.emit("update", liveComment); + liveComment.emit("updatingstatechange", "succeeded"); + return Promise.resolve(); + } + + if (options.holdMutableUpdate) { + return new Promise(() => {}); + } + + return new Promise((resolve) => { + setTimeout(() => { + liveComment.updatedAt = 200; + liveComment.replyCount = 6; + liveComment.replies = { + pages: { + best: { + comments: [{ cid: "reply-1" }], + }, + }, + }; + liveComment.updatingState = "succeeded"; + liveComment.emit("update", liveComment); + liveComment.emit("updatingstatechange", "succeeded"); + resolve(); + }, 0); + }); + }); + return liveComment; +}; + describe("comments store", () => { beforeAll(async () => { setPkcJs(PkcJsMock); @@ -418,6 +467,74 @@ describe("comments store", () => { expect(listeners).not.toContain(liveComment); }); + test("addCommentToStore keeps a one-shot sparse comment alive until mutable data loads", async () => { + const commentCid = "one-shot-sparse-followup-cid"; + const liveComment = createSparseComment(commentCid); + const createCommentOrig = mockAccount.pkc.createComment; + mockAccount.pkc.createComment = vi.fn().mockResolvedValue(liveComment); + + try { + await act(async () => { + await commentsStore.getState().addCommentToStore(commentCid, mockAccount); + }); + + await tlWaitFor(() => expect(liveComment.update).toHaveBeenCalledTimes(2)); + await tlWaitFor(() => + expect(commentsStore.getState().comments[commentCid]).toEqual( + expect.objectContaining({ + content: "sparse body", + replyCount: 6, + timestamp: 100, + updatedAt: 200, + }), + ), + ); + await tlWaitFor(() => + expect(commentsStore.getState().comments[commentCid]?.replies?.pages.best.comments).toEqual( + [{ cid: "reply-1" }], + ), + ); + expect(liveComment.stop).toHaveBeenCalledTimes(1); + expect(listeners).not.toContain(liveComment); + } finally { + mockAccount.pkc.createComment = createCommentOrig; + } + }); + + test("startCommentAutoUpdate continues a sparse IPFS comment update to the mutable comment update", async () => { + const commentCid = "auto-update-sparse-followup-cid"; + const liveComment = createSparseComment(commentCid); + const createCommentOrig = mockAccount.pkc.createComment; + mockAccount.pkc.createComment = vi.fn().mockResolvedValue(liveComment); + + try { + await act(async () => { + await commentsStore.getState().startCommentAutoUpdate(commentCid, "sub-1", mockAccount); + }); + + await tlWaitFor(() => expect(liveComment.update).toHaveBeenCalledTimes(2)); + await tlWaitFor(() => + expect(commentsStore.getState().comments[commentCid]).toEqual( + expect.objectContaining({ + content: "sparse body", + replyCount: 6, + timestamp: 100, + updatedAt: 200, + }), + ), + ); + expect(liveComment.stop).not.toHaveBeenCalled(); + + await act(async () => { + await commentsStore.getState().stopCommentAutoUpdate(commentCid, "sub-1"); + }); + expect(liveComment.stop).toHaveBeenCalledTimes(1); + expect(listeners).not.toContain(liveComment); + } finally { + mockAccount.pkc.createComment = createCommentOrig; + } + }); + test("refreshComment updates the store once and stops again when auto-update is disabled", async () => { const commentCid = "refresh-comment-cid"; const createCommentOriginal = mockAccount.pkc.createComment.bind(mockAccount.pkc); @@ -445,6 +562,73 @@ describe("comments store", () => { mockAccount.pkc.createComment = createCommentOriginal; }); + test("refreshComment resolves after a sparse comment follow-up loads mutable data", async () => { + const commentCid = "refresh-sparse-followup-cid"; + const liveComment = createSparseComment(commentCid); + const createCommentOrig = mockAccount.pkc.createComment; + mockAccount.pkc.createComment = vi.fn().mockResolvedValue(liveComment); + + try { + const refreshedComment = await commentsStore + .getState() + .refreshComment(commentCid, mockAccount); + + expect(liveComment.update).toHaveBeenCalledTimes(2); + expect(refreshedComment).toEqual( + expect.objectContaining({ + content: "sparse body", + replyCount: 6, + timestamp: 100, + updatedAt: 200, + }), + ); + await tlWaitFor(() => + expect(commentsStore.getState().comments[commentCid]?.replies?.pages.best.comments).toEqual( + [{ cid: "reply-1" }], + ), + ); + expect(liveComment.stop).toHaveBeenCalledTimes(2); + expect(listeners).not.toContain(liveComment); + } finally { + mockAccount.pkc.createComment = createCommentOrig; + } + }); + + test("resetCommentsStore clears pending sparse follow-up bookkeeping", async () => { + const commentCid = "reset-sparse-followup-cid"; + const pendingComment = createSparseComment(commentCid, { holdMutableUpdate: true }); + const refetchedComment = createSparseComment(commentCid); + const createCommentOrig = mockAccount.pkc.createComment; + mockAccount.pkc.createComment = vi + .fn() + .mockResolvedValueOnce(pendingComment) + .mockResolvedValueOnce(refetchedComment); + + try { + await act(async () => { + await commentsStore.getState().addCommentToStore(commentCid, mockAccount); + }); + await tlWaitFor(() => expect(pendingComment.update).toHaveBeenCalledTimes(2)); + + await resetCommentsDatabaseAndStore(); + + await act(async () => { + await commentsStore.getState().addCommentToStore(commentCid, mockAccount); + }); + await tlWaitFor(() => expect(refetchedComment.update).toHaveBeenCalledTimes(2)); + await tlWaitFor(() => + expect(commentsStore.getState().comments[commentCid]).toEqual( + expect.objectContaining({ + replyCount: 6, + updatedAt: 200, + }), + ), + ); + } finally { + mockAccount.pkc.createComment = createCommentOrig; + } + }); + test("refreshComment cleans up legacy listeners and rejects on comment error", async () => { const commentCid = "legacy-refresh-error-cid"; const legacyComment: any = new EventEmitter(); diff --git a/src/stores/comments/comments-store.ts b/src/stores/comments/comments-store.ts index 314dd7a4..fc089e7d 100644 --- a/src/stores/comments/comments-store.ts +++ b/src/stores/comments/comments-store.ts @@ -19,6 +19,7 @@ const commentAutoUpdateSubscribers: { [commentCid: string]: { [subscriberId: string]: true }; } = {}; const stopCommentAfterNextUpdate: { [commentCid: string]: boolean } = {}; +const sparseCommentFollowupRequested: { [commentCid: string]: boolean } = {}; const initializedComments = new WeakSet(); const trackedListeners = new WeakSet(); @@ -135,6 +136,22 @@ const commentsStore = createStore((setState: Function, getState: }); }; + const clearCommentUpdateFollowup = (commentCid: string) => { + delete sparseCommentFollowupRequested[commentCid]; + }; + + const isSparseCommentUpdate = (comment: Comment) => !!comment?.timestamp && !comment?.updatedAt; + + // A CID-only comment update can settle after immutable IPFS data; one follow-up + // update gives PKC a chance to load mutable community data such as replies. + const shouldRequestSparseCommentFollowup = (commentCid: string, comment: Comment) => + !sparseCommentFollowupRequested[commentCid] && + (stopCommentAfterNextUpdate[commentCid] || hasCommentAutoUpdateSubscribers(commentCid)) && + isSparseCommentUpdate(comment); + + const shouldWaitForSparseCommentFollowup = (commentCid: string, comment: Comment) => + sparseCommentFollowupRequested[commentCid] && isSparseCommentUpdate(comment); + const initializeComment = (commentCid: string, comment: Comment, account: Account) => { if (initializedComments.has(comment as object)) { liveComments[commentCid] = comment; @@ -156,6 +173,27 @@ const commentsStore = createStore((setState: Function, getState: }, })); + if ( + updatingState === "succeeded" && + shouldRequestSparseCommentFollowup(commentCid, comment) + ) { + sparseCommentFollowupRequested[commentCid] = true; + requestCommentUpdate(commentCid, comment, { + stopAfterNextUpdate: !!stopCommentAfterNextUpdate[commentCid], + }); + return; + } + + if ( + updatingState === "succeeded" && + (comment.updatedAt || sparseCommentFollowupRequested[commentCid]) + ) { + clearCommentUpdateFollowup(commentCid); + } + if (updatingState === "failed") { + clearCommentUpdateFollowup(commentCid); + } + if (updatingState === "succeeded" || updatingState === "failed") { maybeStopCommentAfterOneShotUpdate(commentCid, comment); } @@ -254,15 +292,19 @@ const commentsStore = createStore((setState: Function, getState: delete stopCommentAfterNextUpdate[commentCid]; } - comment - ?.update?.() - .catch((error: unknown) => log.trace("comment.update error", { commentCid, comment, error })); + comment?.update?.().catch((error: unknown) => { + clearCommentUpdateFollowup(commentCid); + log.trace("comment.update error", { commentCid, comment, error }); + }); }; const waitForCommentUpdateCycle = (commentCid: string, comment: Comment) => new Promise((resolve, reject) => { const onUpdatingStateChange = (updatingState: string) => { if (updatingState === "succeeded") { + if (shouldWaitForSparseCommentFollowup(commentCid, comment)) { + return; + } cleanup(); resolve(normalizeCommentCommunityAddress(utils.clone(comment)) as Comment); return; @@ -456,6 +498,9 @@ export const resetCommentsStore = async () => { for (const commentCid in stopCommentAfterNextUpdate) { delete stopCommentAfterNextUpdate[commentCid]; } + for (const commentCid in sparseCommentFollowupRequested) { + delete sparseCommentFollowupRequested[commentCid]; + } for (const commentCid in liveCommentPromises) { delete liveCommentPromises[commentCid]; } From 359f7d07f02cf75745c0dfe7860a94ed49557589 Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Sun, 7 Jun 2026 11:52:39 +0700 Subject: [PATCH 2/3] fix(comments): clear sparse follow-up state on stop --- src/stores/comments/comments-store.test.ts | 95 +++++++++++++++++++++- src/stores/comments/comments-store.ts | 9 +- 2 files changed, 97 insertions(+), 7 deletions(-) diff --git a/src/stores/comments/comments-store.test.ts b/src/stores/comments/comments-store.test.ts index f8841803..f0d564c3 100644 --- a/src/stores/comments/comments-store.test.ts +++ b/src/stores/comments/comments-store.test.ts @@ -16,7 +16,16 @@ import repliesPagesStore from "../replies-pages"; let mockAccount: any; let accountsGetState: typeof accountsStore.getState; -const createSparseComment = (commentCid: string, options: { holdMutableUpdate?: boolean } = {}) => { +const createSparseComment = ( + commentCid: string, + options: { + holdMutableUpdate?: boolean; + pendingUpdateCalls?: number[]; + sparseUpdateCalls?: number[]; + } = {}, +) => { + const pendingUpdateCalls = options.pendingUpdateCalls || (options.holdMutableUpdate ? [2] : []); + const sparseUpdateCalls = options.sparseUpdateCalls || [1]; const liveComment: any = new EventEmitter(); liveComment.cid = commentCid; liveComment.clients = {}; @@ -28,19 +37,23 @@ const createSparseComment = (commentCid: string, options: { holdMutableUpdate?: return Promise.resolve(); }); liveComment.update = vi.fn().mockImplementation(() => { + const updateCallNumber = liveComment.update.mock.calls.length; liveComment.updatingState = "fetching-ipfs"; liveComment.emit("updatingstatechange", "fetching-ipfs"); - if (liveComment.update.mock.calls.length === 1) { + if (sparseUpdateCalls.includes(updateCallNumber)) { liveComment.content = "sparse body"; liveComment.timestamp = 100; + delete liveComment.updatedAt; + delete liveComment.replyCount; + delete liveComment.replies; liveComment.updatingState = "succeeded"; liveComment.emit("update", liveComment); liveComment.emit("updatingstatechange", "succeeded"); return Promise.resolve(); } - if (options.holdMutableUpdate) { + if (pendingUpdateCalls.includes(updateCallNumber)) { return new Promise(() => {}); } @@ -629,6 +642,82 @@ describe("comments store", () => { } }); + test("stopCommentAutoUpdate clears pending sparse follow-up bookkeeping", async () => { + const commentCid = "stop-sparse-followup-cid"; + const pendingComment = createSparseComment(commentCid, { holdMutableUpdate: true }); + const refetchedComment = createSparseComment(commentCid); + const createCommentOrig = mockAccount.pkc.createComment; + mockAccount.pkc.createComment = vi + .fn() + .mockResolvedValueOnce(pendingComment) + .mockResolvedValueOnce(refetchedComment); + + try { + await act(async () => { + await commentsStore.getState().startCommentAutoUpdate(commentCid, "sub-1", mockAccount); + }); + await tlWaitFor(() => expect(pendingComment.update).toHaveBeenCalledTimes(2)); + + await act(async () => { + await commentsStore.getState().stopCommentAutoUpdate(commentCid, "sub-1"); + }); + expect(pendingComment.stop).toHaveBeenCalledTimes(1); + expect(listeners).not.toContain(pendingComment); + + await act(async () => { + await commentsStore.getState().startCommentAutoUpdate(commentCid, "sub-2", mockAccount); + }); + await tlWaitFor(() => expect(refetchedComment.update).toHaveBeenCalledTimes(2)); + await tlWaitFor(() => + expect(commentsStore.getState().comments[commentCid]).toEqual( + expect.objectContaining({ + replyCount: 6, + updatedAt: 200, + }), + ), + ); + + await act(async () => { + await commentsStore.getState().stopCommentAutoUpdate(commentCid, "sub-2"); + }); + } finally { + mockAccount.pkc.createComment = createCommentOrig; + } + }); + + test("refreshComment clears pending sparse follow-up bookkeeping before stopping", async () => { + const commentCid = "refresh-pending-sparse-followup-cid"; + const liveComment = createSparseComment(commentCid, { + pendingUpdateCalls: [2], + sparseUpdateCalls: [1, 3], + }); + const createCommentOrig = mockAccount.pkc.createComment; + mockAccount.pkc.createComment = vi.fn().mockResolvedValue(liveComment); + + try { + await act(async () => { + await commentsStore.getState().addCommentToStore(commentCid, mockAccount); + }); + await tlWaitFor(() => expect(liveComment.update).toHaveBeenCalledTimes(2)); + + const refreshedComment = await commentsStore + .getState() + .refreshComment(commentCid, mockAccount); + + expect(liveComment.update).toHaveBeenCalledTimes(4); + expect(refreshedComment).toEqual( + expect.objectContaining({ + replyCount: 6, + updatedAt: 200, + }), + ); + expect(liveComment.stop).toHaveBeenCalledTimes(2); + expect(listeners).not.toContain(liveComment); + } finally { + mockAccount.pkc.createComment = createCommentOrig; + } + }); + test("refreshComment cleans up legacy listeners and rejects on comment error", async () => { const commentCid = "legacy-refresh-error-cid"; const legacyComment: any = new EventEmitter(); diff --git a/src/stores/comments/comments-store.ts b/src/stores/comments/comments-store.ts index fc089e7d..422a0d2a 100644 --- a/src/stores/comments/comments-store.ts +++ b/src/stores/comments/comments-store.ts @@ -111,7 +111,12 @@ const commentsStore = createStore((setState: Function, getState: return normalizedComment; }; + const clearCommentUpdateFollowup = (commentCid: string) => { + delete sparseCommentFollowupRequested[commentCid]; + }; + const stopLiveComment = async (commentCid: string, comment?: Comment) => { + clearCommentUpdateFollowup(commentCid); const liveComment = comment || liveComments[commentCid]; if (typeof liveComment?.stop !== "function") { return; @@ -136,10 +141,6 @@ const commentsStore = createStore((setState: Function, getState: }); }; - const clearCommentUpdateFollowup = (commentCid: string) => { - delete sparseCommentFollowupRequested[commentCid]; - }; - const isSparseCommentUpdate = (comment: Comment) => !!comment?.timestamp && !comment?.updatedAt; // A CID-only comment update can settle after immutable IPFS data; one follow-up From 625f3e6cc34cc84fcf766ef2b1cd14c2bace6ff5 Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Sun, 7 Jun 2026 12:17:28 +0700 Subject: [PATCH 3/3] fix(comments): handle sparse follow-up update errors --- src/stores/comments/comments-store.test.ts | 135 +++++++++++++++++++++ src/stores/comments/comments-store.ts | 36 +++++- 2 files changed, 169 insertions(+), 2 deletions(-) diff --git a/src/stores/comments/comments-store.test.ts b/src/stores/comments/comments-store.test.ts index f0d564c3..ae69147a 100644 --- a/src/stores/comments/comments-store.test.ts +++ b/src/stores/comments/comments-store.test.ts @@ -21,11 +21,15 @@ const createSparseComment = ( options: { holdMutableUpdate?: boolean; pendingUpdateCalls?: number[]; + rejectUpdateCalls?: number[]; sparseUpdateCalls?: number[]; + updateError?: Error; } = {}, ) => { const pendingUpdateCalls = options.pendingUpdateCalls || (options.holdMutableUpdate ? [2] : []); + const rejectUpdateCalls = options.rejectUpdateCalls || []; const sparseUpdateCalls = options.sparseUpdateCalls || [1]; + const updateError = options.updateError || Error("sparse follow-up failed"); const liveComment: any = new EventEmitter(); liveComment.cid = commentCid; liveComment.clients = {}; @@ -53,6 +57,10 @@ const createSparseComment = ( return Promise.resolve(); } + if (rejectUpdateCalls.includes(updateCallNumber)) { + return Promise.reject(updateError); + } + if (pendingUpdateCalls.includes(updateCallNumber)) { return new Promise(() => {}); } @@ -292,6 +300,77 @@ describe("comments store", () => { updateSpy.mockRestore(); }); + test("comment.update catch records fallback error when comment cannot emit errors", async () => { + const commentCid = "update-reject-no-emit-cid"; + const legacyComment: any = { + cid: commentCid, + timestamp: 1, + clients: {}, + on: vi.fn(), + once: vi.fn(), + update: vi.fn().mockRejectedValue("update failed"), + stop: vi.fn().mockResolvedValue(undefined), + removeAllListeners: vi.fn(), + }; + const createCommentOrig = mockAccount.pkc.createComment; + mockAccount.pkc.createComment = vi.fn().mockResolvedValue(legacyComment); + + try { + await act(async () => { + await commentsStore.getState().addCommentToStore(commentCid, mockAccount); + }); + + await tlWaitFor(() => + expect(commentsStore.getState().errors[commentCid]?.[0]?.message).toBe( + "comment update failed", + ), + ); + await tlWaitFor(() => expect(legacyComment.stop).toHaveBeenCalledTimes(1)); + } finally { + mockAccount.pkc.createComment = createCommentOrig; + } + }); + + test("comment.update catch records update error when error emit throws", async () => { + const commentCid = "update-reject-emit-throws-cid"; + const updateError = Error("update failed"); + const emitError = Error("emit failed"); + const legacyComment: any = { + cid: commentCid, + timestamp: 1, + clients: {}, + emit: vi.fn(() => { + throw emitError; + }), + on: vi.fn(), + once: vi.fn(), + update: vi.fn().mockRejectedValue(updateError), + stop: vi.fn().mockResolvedValue(undefined), + removeAllListeners: vi.fn(), + }; + const createCommentOrig = mockAccount.pkc.createComment; + const traceSpy = vi.spyOn(log, "trace").mockImplementation(() => {}); + mockAccount.pkc.createComment = vi.fn().mockResolvedValue(legacyComment); + + try { + await act(async () => { + await commentsStore.getState().addCommentToStore(commentCid, mockAccount); + }); + + await tlWaitFor(() => + expect(commentsStore.getState().errors[commentCid]?.[0]).toBe(updateError), + ); + expect(traceSpy).toHaveBeenCalledWith( + "comment.update error event error", + expect.objectContaining({ comment: legacyComment, error: emitError }), + ); + await tlWaitFor(() => expect(legacyComment.stop).toHaveBeenCalledTimes(1)); + } finally { + mockAccount.pkc.createComment = createCommentOrig; + traceSpy.mockRestore(); + } + }); + test("comment update callback calls addRepliesPageCommentsToStore", async () => { const commentCid = "update-cb-cid"; const addRepliesSpy = vi.fn(); @@ -514,6 +593,32 @@ describe("comments store", () => { } }); + test("addCommentToStore stops one-shot sparse follow-up when update rejects", async () => { + const commentCid = "one-shot-sparse-followup-reject-cid"; + const updateError = Error("sparse follow-up failed"); + const liveComment = createSparseComment(commentCid, { + rejectUpdateCalls: [2], + updateError, + }); + const createCommentOrig = mockAccount.pkc.createComment; + mockAccount.pkc.createComment = vi.fn().mockResolvedValue(liveComment); + + try { + await act(async () => { + await commentsStore.getState().addCommentToStore(commentCid, mockAccount); + }); + + await tlWaitFor(() => expect(liveComment.update).toHaveBeenCalledTimes(2)); + await tlWaitFor(() => expect(liveComment.stop).toHaveBeenCalledTimes(1)); + await tlWaitFor(() => + expect(commentsStore.getState().errors[commentCid]?.[0]).toBe(updateError), + ); + expect(listeners).not.toContain(liveComment); + } finally { + mockAccount.pkc.createComment = createCommentOrig; + } + }); + test("startCommentAutoUpdate continues a sparse IPFS comment update to the mutable comment update", async () => { const commentCid = "auto-update-sparse-followup-cid"; const liveComment = createSparseComment(commentCid); @@ -607,6 +712,36 @@ describe("comments store", () => { } }); + test("refreshComment rejects when sparse follow-up update rejects", async () => { + const commentCid = "refresh-sparse-followup-reject-cid"; + const updateError = Error("sparse refresh follow-up failed"); + const liveComment = createSparseComment(commentCid, { + rejectUpdateCalls: [2], + updateError, + }); + const createCommentOrig = mockAccount.pkc.createComment; + mockAccount.pkc.createComment = vi.fn().mockResolvedValue(liveComment); + + try { + const refreshPromise = commentsStore.getState().refreshComment(commentCid, mockAccount); + const refreshTimeout = new Promise((_, reject) => { + setTimeout(() => reject(Error("refresh timed out")), 100); + }); + + await expect(Promise.race([refreshPromise, refreshTimeout])).rejects.toThrow( + "sparse refresh follow-up failed", + ); + await tlWaitFor(() => expect(liveComment.update).toHaveBeenCalledTimes(2)); + await tlWaitFor(() => expect(liveComment.stop).toHaveBeenCalledTimes(2)); + await tlWaitFor(() => + expect(commentsStore.getState().errors[commentCid]?.[0]).toBe(updateError), + ); + expect(listeners).not.toContain(liveComment); + } finally { + mockAccount.pkc.createComment = createCommentOrig; + } + }); + test("resetCommentsStore clears pending sparse follow-up bookkeeping", async () => { const commentCid = "reset-sparse-followup-cid"; const pendingComment = createSparseComment(commentCid, { holdMutableUpdate: true }); diff --git a/src/stores/comments/comments-store.ts b/src/stores/comments/comments-store.ts index 422a0d2a..b6c3a01b 100644 --- a/src/stores/comments/comments-store.ts +++ b/src/stores/comments/comments-store.ts @@ -141,6 +141,38 @@ const commentsStore = createStore((setState: Function, getState: }); }; + const toCommentUpdateError = (error: unknown) => + error instanceof Error ? error : Error("comment update failed"); + + const emitCommentError = (comment: Comment, error: Error) => { + const emit = (comment as any)?.emit; + if (typeof emit !== "function") { + return; + } + try { + emit.call(comment, "error", error); + } catch (emitError) { + if (emitError !== error) { + log.trace("comment.update error event error", { comment, error: emitError }); + } + } + }; + + const handleCommentUpdateError = (commentCid: string, comment: Comment, error: unknown) => { + const updateError = toCommentUpdateError(error); + clearCommentUpdateFollowup(commentCid); + + if (!getState().errors[commentCid]?.includes(updateError)) { + emitCommentError(comment, updateError); + if (!getState().errors[commentCid]?.includes(updateError)) { + addCommentError(commentCid, updateError); + } + } + + maybeStopCommentAfterOneShotUpdate(commentCid, comment); + return updateError; + }; + const isSparseCommentUpdate = (comment: Comment) => !!comment?.timestamp && !comment?.updatedAt; // A CID-only comment update can settle after immutable IPFS data; one follow-up @@ -294,8 +326,8 @@ const commentsStore = createStore((setState: Function, getState: } comment?.update?.().catch((error: unknown) => { - clearCommentUpdateFollowup(commentCid); - log.trace("comment.update error", { commentCid, comment, error }); + const updateError = handleCommentUpdateError(commentCid, comment, error); + log.trace("comment.update error", { commentCid, comment, error: updateError }); }); };