From 6dacdbee397737c775c43bcce0268c74ab97a4c9 Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Mon, 25 May 2026 18:10:57 +0700 Subject: [PATCH] fix(communities): ignore stale community errors --- README.md | 2 + llms-full.txt | 8 +- llms.txt | 2 +- src/hooks/communities.test.ts | 58 ++++++------- .../communities/communities-store.test.ts | 82 +++++++++++++++++++ src/stores/communities/communities-store.ts | 55 +++++++++++-- 6 files changed, 172 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index dd8d50d6..5c3a6610 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,8 @@ useResolvedCommunityAddress({communityAddress: string, cache: boolean}): {resolv Pass `{ publicKey, name }` when you have both so `pkc-js` can fetch through the public key and resolve the name in the background. `communityAddress`, `communityAddresses`, and `communityRefs` are no longer accepted by these hooks. +`useCommunity` only exposes community error events that are not superseded by a later `update` event. Transient fetch errors are delayed briefly before surfacing through `error` or `errors`. + #### Authors Hooks ``` diff --git a/llms-full.txt b/llms-full.txt index a755f2fe..36cee1c7 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -167,6 +167,8 @@ useResolvedCommunityAddress({communityAddress: string, cache: boolean}): {resolv Pass `{ publicKey, name }` when you have both so `pkc-js` can fetch through the public key and resolve the name in the background. `communityAddress`, `communityAddresses`, and `communityRefs` are no longer accepted by these hooks. +`useCommunity` only exposes community error events that are not superseded by a later `update` event. Transient fetch errors are delayed briefly before surfacing through `error` or `errors`. + #### Authors Hooks ``` @@ -3122,12 +3124,16 @@ Avoid GitHub MCP and browser MCP servers for this project because they add signi Source: https://github.com/bitsocialnet/bitsocial-react-hooks/blob/master/CHANGELOG.md ```markdown +## [0.1.11](https://github.com/bitsocialnet/bitsocial-react-hooks/compare/v0.1.10...v0.1.11) (2026-05-20) + + + ## [0.1.10](https://github.com/bitsocialnet/bitsocial-react-hooks/compare/v0.1.9...v0.1.10) (2026-05-15) ### Bug Fixes -* **deps:** upgrade pkc-js to 0.0.33 ([3438c0d](https://github.com/bitsocialnet/bitsocial-react-hooks/commit/3438c0d8ade34180a66b8ddbdc92ba8c970b4e5c)) +* **deps:** upgrade pkc-js to 0.0.30 ([9942a82](https://github.com/bitsocialnet/bitsocial-react-hooks/commit/9942a82def8330e49a8252653a7bc16abd6819a4)) diff --git a/llms.txt b/llms.txt index e8b195dd..264848ad 100644 --- a/llms.txt +++ b/llms.txt @@ -38,5 +38,5 @@ This file is generated by `scripts/generate-llms-files.mjs`. Do not hand-edit it ## Optional -- [Changelog](https://github.com/bitsocialnet/bitsocial-react-hooks/blob/master/CHANGELOG.md): * **deps:** upgrade pkc-js to 0.0.33 ([3438c0d](https://github.com/bitsocialnet/bitsocial-react-hooks/commit/3438c0d8ade34180a66b8ddbdc92ba8c970b4e5c)) +- [Changelog](https://github.com/bitsocialnet/bitsocial-react-hooks/blob/master/CHANGELOG.md): * **deps:** upgrade pkc-js to 0.0.30 ([9942a82](https://github.com/bitsocialnet/bitsocial-react-hooks/commit/9942a82def8330e49a8252653a7bc16abd6819a4)) - [TODO](https://github.com/bitsocialnet/bitsocial-react-hooks/blob/master/docs/TODO.md): - e2e test to publish to an electron sub - async useAuthorAddress hook (because resolving ETH address synchronously is too slow) - implement sort by active - implement showing your own pending replies in a comment (wh... diff --git a/src/hooks/communities.test.ts b/src/hooks/communities.test.ts index 9f4c4cc6..e54a3d46 100644 --- a/src/hooks/communities.test.ts +++ b/src/hooks/communities.test.ts @@ -322,41 +322,43 @@ describe("communities", () => { } }); - test("has error events", async () => { + test("has unsuperseded error events", async () => { // mock update to save community instance const communityUpdate = Community.prototype.update; const updatingCommunities: any = []; Community.prototype.update = function () { updatingCommunities.push(this); - return communityUpdate.bind(this)(); + return Promise.resolve(); }; - const rendered = renderHook((communityAddress) => - useCommunity({ community: toCommunity(communityAddress) }), - ); - const waitFor = testUtils.createWaitFor(rendered); - rendered.rerender("community address"); - - // emit error event - await waitFor(() => updatingCommunities.length > 0); - updatingCommunities[0].emit("error", Error("error 1")); - - // first error - await waitFor(() => rendered.result.current.error.message === "error 1"); - expect(rendered.result.current.error.message).toBe("error 1"); - expect(rendered.result.current.errors[0].message).toBe("error 1"); - expect(rendered.result.current.errors.length).toBe(1); - - // second error - updatingCommunities[0].emit("error", Error("error 2")); - await waitFor(() => rendered.result.current.error.message === "error 2"); - expect(rendered.result.current.error.message).toBe("error 2"); - expect(rendered.result.current.errors[0].message).toBe("error 1"); - expect(rendered.result.current.errors[1].message).toBe("error 2"); - expect(rendered.result.current.errors.length).toBe(2); - - // restore mock - Community.prototype.update = communityUpdate; + try { + const rendered = renderHook((communityAddress) => + useCommunity({ community: toCommunity(communityAddress) }), + ); + const waitFor = testUtils.createWaitFor(rendered, { timeout: 3000 }); + rendered.rerender("community address"); + + // emit error event + await waitFor(() => updatingCommunities.length > 0); + updatingCommunities[0].emit("error", Error("error 1")); + + // first error + await waitFor(() => rendered.result.current.error.message === "error 1"); + expect(rendered.result.current.error.message).toBe("error 1"); + expect(rendered.result.current.errors[0].message).toBe("error 1"); + expect(rendered.result.current.errors.length).toBe(1); + + // second error + updatingCommunities[0].emit("error", Error("error 2")); + await waitFor(() => rendered.result.current.error.message === "error 2"); + expect(rendered.result.current.error.message).toBe("error 2"); + expect(rendered.result.current.errors[0].message).toBe("error 1"); + expect(rendered.result.current.errors[1].message).toBe("error 2"); + expect(rendered.result.current.errors.length).toBe(2); + } finally { + // restore mock + Community.prototype.update = communityUpdate; + } }); test("pkc.createCommunity throws adds useCommunity().error", async () => { diff --git a/src/stores/communities/communities-store.test.ts b/src/stores/communities/communities-store.test.ts index a6f5ce21..723baa46 100644 --- a/src/stores/communities/communities-store.test.ts +++ b/src/stores/communities/communities-store.test.ts @@ -291,6 +291,88 @@ describe("communities store", () => { mockAccount.pkc.createCommunity = createOrig; }); + test("community error event waits before reaching store errors", async () => { + const address = "delayed-error-address"; + const pkc = await PkcJsMock(); + const community = await pkc.createCommunity({ address }); + const updateSpy = vi.spyOn(community, "update").mockResolvedValue(undefined); + const createOrig = mockAccount.pkc.createCommunity; + mockAccount.pkc.createCommunity = vi.fn().mockResolvedValue(community); + + try { + await act(async () => { + await communitiesStore.getState().addCommunityToStore(address, mockAccount); + }); + + vi.useFakeTimers(); + const error = new Error("fetch failed"); + act(() => { + community.emit("error", error); + }); + + expect(communitiesStore.getState().errors[address]).toBeUndefined(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(999); + }); + expect(communitiesStore.getState().errors[address]).toBeUndefined(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + expect(communitiesStore.getState().errors[address]).toEqual([error]); + } finally { + vi.useRealTimers(); + mockAccount.pkc.createCommunity = createOrig; + updateSpy.mockRestore(); + } + }); + + test("community update event discards earlier pending and stored errors", async () => { + const address = "stale-error-address"; + const pkc = await PkcJsMock(); + const community = await pkc.createCommunity({ address }); + const updateSpy = vi.spyOn(community, "update").mockResolvedValue(undefined); + const createOrig = mockAccount.pkc.createCommunity; + mockAccount.pkc.createCommunity = vi.fn().mockResolvedValue(community); + + try { + await act(async () => { + await communitiesStore.getState().addCommunityToStore(address, mockAccount); + }); + + vi.useFakeTimers(); + const pendingError = new Error("pending fetch failed"); + act(() => { + community.emit("error", pendingError); + community.emit("update", community); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + + expect(communitiesStore.getState().errors[address]).toBeUndefined(); + + const storedError = new Error("stored fetch failed"); + act(() => { + community.emit("error", storedError); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + expect(communitiesStore.getState().errors[address]).toEqual([storedError]); + + act(() => { + community.emit("update", community); + }); + expect(communitiesStore.getState().errors[address]).toBeUndefined(); + } finally { + vi.useRealTimers(); + mockAccount.pkc.createCommunity = createOrig; + updateSpy.mockRestore(); + } + }); + test("createCommunity with no signer asserts address must be undefined", async () => { const pkc = await PkcJsMock(); const community = await pkc.createCommunity({ address: "new-sub-address" }); diff --git a/src/stores/communities/communities-store.ts b/src/stores/communities/communities-store.ts index 4984de9b..49e79e9f 100644 --- a/src/stores/communities/communities-store.ts +++ b/src/stores/communities/communities-store.ts @@ -28,6 +28,11 @@ import { let pkcGetCommunityPending: { [key: string]: boolean } = {}; +const COMMUNITY_ERROR_UPDATE_GRACE_MS = 1000; +const pendingCommunityErrorTimers: { + [communityKey: string]: ReturnType[]; +} = {}; + const createCommunityWithLookupFallback = async ( pkc: any, communityLookupOptions: { address?: string; name?: string; publicKey?: string }, @@ -44,6 +49,43 @@ const createCommunityWithLookupFallback = async ( // reset all event listeners in between tests const listeners: any = []; +const clearPendingCommunityErrors = (communityKey: string) => { + pendingCommunityErrorTimers[communityKey]?.forEach((timeout) => clearTimeout(timeout)); + delete pendingCommunityErrorTimers[communityKey]; +}; + +const clearStoredCommunityErrors = (state: CommunitiesState, communityKey: string) => { + if (!state.errors[communityKey]) { + return state.errors; + } + const nextErrors = { ...state.errors }; + delete nextErrors[communityKey]; + return nextErrors; +}; + +const scheduleCommunityError = (setState: Function, communityKey: string, error: Error) => { + const timeout = setTimeout(() => { + pendingCommunityErrorTimers[communityKey] = ( + pendingCommunityErrorTimers[communityKey] || [] + ).filter((pendingTimeout) => pendingTimeout !== timeout); + if ((pendingCommunityErrorTimers[communityKey] || []).length === 0) { + delete pendingCommunityErrorTimers[communityKey]; + } + setState((state: CommunitiesState) => { + const communityErrors = state.errors[communityKey] || []; + return { + ...state, + errors: { ...state.errors, [communityKey]: [...communityErrors, error] }, + }; + }); + }, COMMUNITY_ERROR_UPDATE_GRACE_MS); + + pendingCommunityErrorTimers[communityKey] = [ + ...(pendingCommunityErrorTimers[communityKey] || []), + timeout, + ]; +}; + export type CommunitiesState = { communities: Communities; errors: { [communityAddress: string]: Error[] }; @@ -170,6 +212,11 @@ const communitiesStore = createStore( // the community has published new posts community.on("update", async (updatedCommunity: Community) => { + clearPendingCommunityErrors(communityKey); + setState((state: CommunitiesState) => ({ + ...state, + errors: clearStoredCommunityErrors(state, communityKey), + })); updatedCommunity = utils.clone(updatedCommunity); // add fetchedAt to be able to expire the cache @@ -184,6 +231,7 @@ const communitiesStore = createStore( account, }); setState((state: any) => ({ + ...state, communities: { ...state.communities, [communityKey]: updatedCommunity }, })); @@ -206,11 +254,7 @@ const communitiesStore = createStore( }); community.on("error", (error: Error) => { - setState((state: CommunitiesState) => { - let communityErrors = state.errors[communityKey] || []; - communityErrors = [...communityErrors, error]; - return { ...state, errors: { ...state.errors, [communityKey]: communityErrors } }; - }); + scheduleCommunityError(setState, communityKey, error); }); // set clients on community so the frontend can display it, dont persist in db because a reload cancels updating @@ -399,6 +443,7 @@ const originalState = communitiesStore.getState(); // async function because some stores have async init export const resetCommunitiesStore = async () => { pkcGetCommunityPending = {}; + Object.keys(pendingCommunityErrorTimers).forEach(clearPendingCommunityErrors); // remove all event listeners listeners.forEach((listener: any) => listener.removeAllListeners()); // destroy all component subscriptions to the store