From d908314d412c0fee44dc7814ce9ab578646476fb Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Wed, 27 May 2026 16:24:53 +0700 Subject: [PATCH 1/2] fix(communities): suppress retriable load errors --- .../communities/communities-store.test.ts | 81 +++++++++++++++++++ src/stores/communities/communities-store.ts | 22 ++++- 2 files changed, 99 insertions(+), 4 deletions(-) diff --git a/src/stores/communities/communities-store.test.ts b/src/stores/communities/communities-store.test.ts index 26b285d9..381fd863 100644 --- a/src/stores/communities/communities-store.test.ts +++ b/src/stores/communities/communities-store.test.ts @@ -451,6 +451,87 @@ describe("communities store", () => { } }); + test("community retriable error event stays out of store errors", async () => { + const address = "retriable-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 = Object.assign(new Error("transient fetch failed"), { + details: { retriableError: true }, + }); + act(() => { + community.emit("error", error); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + expect(communitiesStore.getState().errors[address]).toBeUndefined(); + } finally { + vi.useRealTimers(); + mockAccount.pkc.createCommunity = createOrig; + updateSpy.mockRestore(); + } + }); + + test("community non-retriable error event still reaches store errors", async () => { + const address = "non-retriable-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 = Object.assign(new Error("final fetch failed"), { + details: { retriableError: false }, + }); + const laterError = Object.assign(new Error("later final fetch failed"), { + details: { retriableError: false }, + }); + act(() => { + community.emit("error", error); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(500); + }); + expect(communitiesStore.getState().errors[address]).toBeUndefined(); + + act(() => { + community.emit("error", laterError); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(500); + }); + expect(communitiesStore.getState().errors[address]).toEqual([error]); + + await act(async () => { + await vi.advanceTimersByTimeAsync(500); + }); + expect(communitiesStore.getState().errors[address]).toEqual([error, laterError]); + } 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(); diff --git a/src/stores/communities/communities-store.ts b/src/stores/communities/communities-store.ts index c832a420..808e480f 100644 --- a/src/stores/communities/communities-store.ts +++ b/src/stores/communities/communities-store.ts @@ -123,12 +123,26 @@ const clearStoredCommunityErrors = (state: CommunitiesState, communityKey: strin return nextErrors; }; +const isRetriableCommunityLoadError = (error: Error) => { + const details = (error as Error & { details?: unknown }).details; + return Boolean( + details && + typeof details === "object" && + "retriableError" in details && + (details as { retriableError?: unknown }).retriableError === true, + ); +}; + const scheduleCommunityError = (setState: Function, communityKey: string, error: Error) => { + if (isRetriableCommunityLoadError(error)) { + return; + } + const timeout = setTimeout(() => { - pendingCommunityErrorTimers[communityKey] = ( - pendingCommunityErrorTimers[communityKey] || [] - ).filter((pendingTimeout) => pendingTimeout !== timeout); - if ((pendingCommunityErrorTimers[communityKey] || []).length === 0) { + pendingCommunityErrorTimers[communityKey] = pendingCommunityErrorTimers[communityKey].filter( + (pendingTimeout) => pendingTimeout !== timeout, + ); + if (pendingCommunityErrorTimers[communityKey].length === 0) { delete pendingCommunityErrorTimers[communityKey]; } setState((state: CommunitiesState) => { From 04ae6f25d8a812316949b2da941a58002ae0afe0 Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Wed, 27 May 2026 16:42:04 +0700 Subject: [PATCH 2/2] fix(communities): guard cleared error timers --- .../communities/communities-store.test.ts | 35 +++++++++++++++++++ src/stores/communities/communities-store.ts | 11 ++++-- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/stores/communities/communities-store.test.ts b/src/stores/communities/communities-store.test.ts index 381fd863..6dc9f2bc 100644 --- a/src/stores/communities/communities-store.test.ts +++ b/src/stores/communities/communities-store.test.ts @@ -577,6 +577,41 @@ describe("communities store", () => { } }); + test("community error timeout is ignored when pending entry was already cleared", async () => { + const address = "cleared-pending-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); + let clearTimeoutSpy: { mockRestore: () => void } | undefined; + + try { + await act(async () => { + await communitiesStore.getState().addCommunityToStore(address, mockAccount); + }); + + vi.useFakeTimers(); + clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout").mockImplementation(() => undefined); + const pendingError = new Error("cleared 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(); + } finally { + clearTimeoutSpy?.mockRestore(); + 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 808e480f..905d30a1 100644 --- a/src/stores/communities/communities-store.ts +++ b/src/stores/communities/communities-store.ts @@ -139,11 +139,18 @@ const scheduleCommunityError = (setState: Function, communityKey: string, error: } const timeout = setTimeout(() => { - pendingCommunityErrorTimers[communityKey] = pendingCommunityErrorTimers[communityKey].filter( + const pendingTimeouts = pendingCommunityErrorTimers[communityKey]; + if (!pendingTimeouts?.includes(timeout)) { + return; + } + + const remainingTimeouts = pendingTimeouts.filter( (pendingTimeout) => pendingTimeout !== timeout, ); - if (pendingCommunityErrorTimers[communityKey].length === 0) { + if (remainingTimeouts.length === 0) { delete pendingCommunityErrorTimers[communityKey]; + } else { + pendingCommunityErrorTimers[communityKey] = remainingTimeouts; } setState((state: CommunitiesState) => { const communityErrors = state.errors[communityKey] || [];