From 40966f7027e9fdc9eee491843c8a0c3daa59cc25 Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Mon, 25 May 2026 18:10:46 +0700 Subject: [PATCH 1/2] fix(communities): refresh active communities periodically --- README.md | 3 + llms-full.txt | 9 +- llms.txt | 2 +- src/lib/pkc-js/pkc-js-mock-content.ts | 6 ++ src/lib/pkc-js/pkc-js-mock.ts | 6 +- src/stores/accounts/accounts-store.test.ts | 6 +- .../communities/communities-store.test.ts | 90 ++++++++++++++++++- src/stores/communities/communities-store.ts | 42 ++++++++- 8 files changed, 150 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index dd8d50d6..79f432b7 100644 --- a/README.md +++ b/README.md @@ -411,6 +411,9 @@ const { communities } = useCommunities({ ], }); +// fetched communities are refreshed immediately and then every 15 minutes +// so long-lived tabs keep following community IPNS updates + // use without affecting performance const { communities: cachedCommunities } = useCommunities({ communities: [ diff --git a/llms-full.txt b/llms-full.txt index a755f2fe..c17a4e58 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -441,6 +441,9 @@ const { communities } = useCommunities({ ], }); +// fetched communities are refreshed immediately and then every 15 minutes +// so long-lived tabs keep following community IPNS updates + // use without affecting performance const { communities: cachedCommunities } = useCommunities({ communities: [ @@ -3122,12 +3125,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/lib/pkc-js/pkc-js-mock-content.ts b/src/lib/pkc-js/pkc-js-mock-content.ts index 103bd49b..72a3b033 100644 --- a/src/lib/pkc-js/pkc-js-mock-content.ts +++ b/src/lib/pkc-js/pkc-js-mock-content.ts @@ -1251,7 +1251,11 @@ class Community extends EventEmitter { if (this._getCommunityOnFirstUpdate) { return this.simulateGetCommunityOnFirstUpdateEvent(); } + // @ts-ignore + this.updating = false; + this.updatingState = "succeeded"; this.emit("update", this); + this.emit("updatingstatechange", "succeeded"); } async simulateGetCommunityOnFirstUpdateEvent() { @@ -1268,6 +1272,8 @@ class Community extends EventEmitter { this[prop] = props[prop]; } this.posts.getPage = community.posts.getPage; + // @ts-ignore + this.updating = false; this.updatingState = "succeeded"; this.emit("update", this); this.emit("updatingstatechange", "succeeded"); diff --git a/src/lib/pkc-js/pkc-js-mock.ts b/src/lib/pkc-js/pkc-js-mock.ts index 6b97cb01..7e58a670 100644 --- a/src/lib/pkc-js/pkc-js-mock.ts +++ b/src/lib/pkc-js/pkc-js-mock.ts @@ -368,11 +368,6 @@ export class Community extends EventEmitter { async update() { this.updateCalledTimes++; - if (this.updateCalledTimes > 1) { - throw Error( - "with the current hooks, community.update() should be called maximum 1 times, this number might change if the hooks change and is only there to catch bugs, the real comment.update() can be called infinite times", - ); - } if (!this.address) { throw Error(`can't update without community.address`); } @@ -409,6 +404,7 @@ export class Community extends EventEmitter { // @ts-ignore this.updatedAt = this.updatedAt + 1; + this.updating = false; this.updatingState = "succeeded"; this.emit("update", this); this.emit("updatingstatechange", "succeeded"); diff --git a/src/stores/accounts/accounts-store.test.ts b/src/stores/accounts/accounts-store.test.ts index f3c0ab94..e11c6e6a 100644 --- a/src/stores/accounts/accounts-store.test.ts +++ b/src/stores/accounts/accounts-store.test.ts @@ -121,12 +121,12 @@ describe("accounts-store", () => { describe("init edge cases", () => { test("IIFE returns early when BITSOCIAL_REACT_HOOKS_ACCOUNTS_STORE_INITIALIZED_ONCE is set", async () => { - // Flag is set from first init; reset modules and re-import to exercise early-return branch - vi.resetModules(); + // Flag is set from first init; import a query-qualified store module to exercise early-return branch + // without resetting the localforage test driver installed by Vitest setup. // @ts-ignore expect(window.BITSOCIAL_REACT_HOOKS_ACCOUNTS_STORE_INITIALIZED_ONCE).toBe(true); - const mod = await import("./accounts-store"); + const mod = await import("./accounts-store?init-skip"); const freshStore = mod.default; // New module instance; init was skipped so store has default empty state const state = freshStore.getState(); diff --git a/src/stores/communities/communities-store.test.ts b/src/stores/communities/communities-store.test.ts index a6f5ce21..920deeb9 100644 --- a/src/stores/communities/communities-store.test.ts +++ b/src/stores/communities/communities-store.test.ts @@ -1,6 +1,9 @@ import { act } from "@testing-library/react"; import testUtils, { renderHook } from "../../lib/test-utils"; -import communitiesStore, { resetCommunitiesDatabaseAndStore } from "./communities-store"; +import communitiesStore, { + COMMUNITY_UPDATE_INTERVAL_MS, + resetCommunitiesDatabaseAndStore, +} from "./communities-store"; import localForageLru from "../../lib/localforage-lru"; import { setPkcJs } from "../.."; import PkcJsMock, { PKC as BasePkc } from "../../lib/pkc-js/pkc-js-mock"; @@ -93,6 +96,25 @@ describe("communities store", () => { } }); + test("addCommunityToStore fails when structured lookup returns no address", async () => { + const communityRef = { name: "missing-address.eth", publicKey: "missing-address-public-key" }; + const createOrig = mockAccount.pkc.createCommunity; + mockAccount.pkc.createCommunity = vi.fn().mockResolvedValue({}); + + try { + await expect( + communitiesStore.getState().addCommunityToStore(communityRef, mockAccount), + ).rejects.toThrow("failed getting community 'missing-address-public-key'"); + + expect(communitiesStore.getState().communities[communityRef.publicKey]).toBeUndefined(); + expect(communitiesStore.getState().errors[communityRef.publicKey]?.[0].message).toBe( + "communitiesStore.addCommunityToStore failed getting community 'missing-address-public-key'", + ); + } finally { + mockAccount.pkc.createCommunity = createOrig; + } + }); + test("refreshCommunity uses structured community lookups and stores by publicKey", async () => { const communityRef = { name: "refresh-name.eth", publicKey: "refresh-public-key" }; const getOrig = mockAccount.pkc.getCommunity; @@ -113,6 +135,26 @@ describe("communities store", () => { } }); + test("refreshCommunity refreshes string addresses", async () => { + const address = "refresh-string-address"; + const getOrig = mockAccount.pkc.getCommunity; + mockAccount.pkc.getCommunity = vi.fn().mockImplementation(getOrig.bind(mockAccount.pkc)); + + try { + await act(async () => { + await communitiesStore.getState().refreshCommunity(address, mockAccount); + }); + + expect(mockAccount.pkc.getCommunity).toHaveBeenCalledWith({ address }); + expect(communitiesStore.getState().communities[address]).toBeDefined(); + expect(communitiesStore.getState().communities[address]?.fetchedAt).toEqual( + expect.any(Number), + ); + } finally { + mockAccount.pkc.getCommunity = getOrig; + } + }); + test("cached community create failure logs to console", async () => { const address = "cached-fail-address"; const db = localForageLru.createInstance({ name: "bitsocialReactHooks-communities" }); @@ -186,6 +228,40 @@ describe("communities store", () => { updateSpy.mockRestore(); }); + test("addCommunityToStore refreshes community updates every 15 minutes", async () => { + vi.useFakeTimers(); + const address = "periodic-update-address"; + const pkc = await PkcJsMock(); + const community = await pkc.createCommunity({ address }); + const updateSpy = vi.spyOn(community, "update").mockResolvedValue(undefined); + + const createCommunityOrig = mockAccount.pkc.createCommunity; + mockAccount.pkc.createCommunity = vi.fn().mockResolvedValue(community); + + try { + await act(async () => { + await communitiesStore.getState().addCommunityToStore(address, mockAccount); + }); + + expect(updateSpy).toHaveBeenCalledTimes(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(COMMUNITY_UPDATE_INTERVAL_MS); + }); + expect(updateSpy).toHaveBeenCalledTimes(2); + + await act(async () => { + await vi.advanceTimersByTimeAsync(COMMUNITY_UPDATE_INTERVAL_MS); + }); + expect(updateSpy).toHaveBeenCalledTimes(3); + } finally { + mockAccount.pkc.createCommunity = createCommunityOrig; + updateSpy.mockRestore(); + await resetCommunitiesDatabaseAndStore(); + vi.useRealTimers(); + } + }); + test("addCommunityToStore sets errors and throws when createCommunity rejects", async () => { const address = "create-reject-address"; const createOrig = mockAccount.pkc.createCommunity; @@ -305,6 +381,18 @@ describe("communities store", () => { mockAccount.pkc.createCommunity = createOrig; }); + test("createCommunity with signer can create a specific address", async () => { + const address = "signed-community-address"; + + await act(async () => { + await communitiesStore + .getState() + .createCommunity({ address, signer: { address: "signer-address" } }, mockAccount); + }); + + expect(communitiesStore.getState().communities[address]).toBeDefined(); + }); + test("createCommunity with address but no signer throws (branch 251)", async () => { await expect( communitiesStore.getState().createCommunity({ address: "addr-no-signer" }, mockAccount), diff --git a/src/stores/communities/communities-store.ts b/src/stores/communities/communities-store.ts index 4984de9b..784141b1 100644 --- a/src/stores/communities/communities-store.ts +++ b/src/stores/communities/communities-store.ts @@ -26,7 +26,10 @@ import { getPkcGetCommunity, } from "../../lib/pkc-compat"; +export const COMMUNITY_UPDATE_INTERVAL_MS = 15 * 60 * 1000; + let pkcGetCommunityPending: { [key: string]: boolean } = {}; +const communityUpdateIntervals: ReturnType[] = []; const createCommunityWithLookupFallback = async ( pkc: any, @@ -44,6 +47,39 @@ const createCommunityWithLookupFallback = async ( // reset all event listeners in between tests const listeners: any = []; +const updateCommunity = ( + community: Community, + { + communityAddressOrRef, + communityKey, + }: { communityAddressOrRef: string | CommunityIdentifier; communityKey: string }, +) => { + community.update().catch((error: unknown) => + log.trace("community.update error", { + communityAddressOrRef, + communityKey, + community, + error, + }), + ); +}; + +const startCommunityUpdatePolling = ( + community: Community, + { + communityAddressOrRef, + communityKey, + }: { communityAddressOrRef: string | CommunityIdentifier; communityKey: string }, +) => { + updateCommunity(community, { communityAddressOrRef, communityKey }); + communityUpdateIntervals.push( + setInterval( + () => updateCommunity(community, { communityAddressOrRef, communityKey }), + COMMUNITY_UPDATE_INTERVAL_MS, + ), + ); +}; + export type CommunitiesState = { communities: Communities; errors: { [communityAddress: string]: Error[] }; @@ -241,9 +277,7 @@ const communitiesStore = createStore( ); listeners.push(community); - community - .update() - .catch((error: unknown) => log.trace("community.update error", { community, error })); + startCommunityUpdatePolling(community, { communityAddressOrRef, communityKey }); } finally { pkcGetCommunityPending[pendingKey] = false; } @@ -401,6 +435,8 @@ export const resetCommunitiesStore = async () => { pkcGetCommunityPending = {}; // remove all event listeners listeners.forEach((listener: any) => listener.removeAllListeners()); + communityUpdateIntervals.forEach((updateInterval) => clearInterval(updateInterval)); + communityUpdateIntervals.length = 0; // destroy all component subscriptions to the store communitiesStore.destroy(); // restore original state From 78512703b94723ba29466ea14294900dfeaa50b1 Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Mon, 25 May 2026 18:38:51 +0700 Subject: [PATCH 2/2] fix(communities): clean up deleted community polling --- src/stores/accounts/accounts-store.test.ts | 22 +++++---- .../communities/communities-store.test.ts | 47 +++++++++++++++++++ src/stores/communities/communities-store.ts | 43 +++++++++++++---- 3 files changed, 94 insertions(+), 18 deletions(-) diff --git a/src/stores/accounts/accounts-store.test.ts b/src/stores/accounts/accounts-store.test.ts index e11c6e6a..1441c34e 100644 --- a/src/stores/accounts/accounts-store.test.ts +++ b/src/stores/accounts/accounts-store.test.ts @@ -121,17 +121,23 @@ describe("accounts-store", () => { describe("init edge cases", () => { test("IIFE returns early when BITSOCIAL_REACT_HOOKS_ACCOUNTS_STORE_INITIALIZED_ONCE is set", async () => { - // Flag is set from first init; import a query-qualified store module to exercise early-return branch - // without resetting the localforage test driver installed by Vitest setup. // @ts-ignore expect(window.BITSOCIAL_REACT_HOOKS_ACCOUNTS_STORE_INITIALIZED_ONCE).toBe(true); - const mod = await import("./accounts-store?init-skip"); - const freshStore = mod.default; - // New module instance; init was skipped so store has default empty state - const state = freshStore.getState(); - expect(state.accounts).toEqual({}); - expect(state.accountIds).toEqual([]); + const configuredLocalforage = (await import("localforage")).default; + vi.resetModules(); + vi.doMock("localforage", () => ({ default: configuredLocalforage })); + + try { + const mod = await import("./accounts-store"); + const freshStore = mod.default; + // New module instance; init was skipped so store has default empty state + const state = freshStore.getState(); + expect(state.accounts).toEqual({}); + expect(state.accountIds).toEqual([]); + } finally { + vi.doUnmock("localforage"); + } }); }); }); diff --git a/src/stores/communities/communities-store.test.ts b/src/stores/communities/communities-store.test.ts index 920deeb9..5717a6c3 100644 --- a/src/stores/communities/communities-store.test.ts +++ b/src/stores/communities/communities-store.test.ts @@ -262,6 +262,53 @@ describe("communities store", () => { } }); + test("deleteCommunity stops update polling and listeners for deleted communities", async () => { + vi.useFakeTimers(); + const address = "deleted-periodic-address"; + const pkc = await PkcJsMock(); + const liveCommunity = await pkc.createCommunity({ address }); + const deleteCommunity = await pkc.createCommunity({ address }); + const updateSpy = vi.spyOn(liveCommunity, "update").mockResolvedValue(undefined); + const deleteSpy = vi.spyOn(deleteCommunity, "delete").mockResolvedValue(undefined); + + const createCommunityOrig = mockAccount.pkc.createCommunity; + mockAccount.pkc.createCommunity = vi + .fn() + .mockResolvedValueOnce(liveCommunity) + .mockResolvedValueOnce(deleteCommunity); + + try { + await act(async () => { + await communitiesStore.getState().addCommunityToStore(address, mockAccount); + }); + + expect(updateSpy).toHaveBeenCalledTimes(1); + + await act(async () => { + await communitiesStore.getState().deleteCommunity(address, mockAccount); + }); + + expect(deleteSpy).toHaveBeenCalledTimes(1); + expect(communitiesStore.getState().communities[address]).toBeUndefined(); + + liveCommunity.emit("update", liveCommunity); + await act(async () => { + await vi.advanceTimersByTimeAsync(COMMUNITY_UPDATE_INTERVAL_MS); + }); + + expect(updateSpy).toHaveBeenCalledTimes(1); + expect(communitiesStore.getState().communities[address]).toBeUndefined(); + const db = localForageLru.createInstance({ name: "bitsocialReactHooks-communities" }); + expect(await db.getItem(address)).toBeUndefined(); + } finally { + mockAccount.pkc.createCommunity = createCommunityOrig; + updateSpy.mockRestore(); + deleteSpy.mockRestore(); + await resetCommunitiesDatabaseAndStore(); + vi.useRealTimers(); + } + }); + test("addCommunityToStore sets errors and throws when createCommunity rejects", async () => { const address = "create-reject-address"; const createOrig = mockAccount.pkc.createCommunity; diff --git a/src/stores/communities/communities-store.ts b/src/stores/communities/communities-store.ts index 784141b1..dbaec157 100644 --- a/src/stores/communities/communities-store.ts +++ b/src/stores/communities/communities-store.ts @@ -29,7 +29,16 @@ import { export const COMMUNITY_UPDATE_INTERVAL_MS = 15 * 60 * 1000; let pkcGetCommunityPending: { [key: string]: boolean } = {}; -const communityUpdateIntervals: ReturnType[] = []; +// Key pollers by community so delete/reset can stop the matching live instance. +const communityUpdatePollers: { + [communityKey: string]: { + community: any; + updateInterval: ReturnType; + }; +} = {}; + +// reset all event listeners in between tests +const listeners: any = []; const createCommunityWithLookupFallback = async ( pkc: any, @@ -44,9 +53,6 @@ const createCommunityWithLookupFallback = async ( throw Error(`communitiesStore.addCommunityToStore failed getting community '${communityKey}'`); }; -// reset all event listeners in between tests -const listeners: any = []; - const updateCommunity = ( community: Community, { @@ -71,13 +77,30 @@ const startCommunityUpdatePolling = ( communityKey, }: { communityAddressOrRef: string | CommunityIdentifier; communityKey: string }, ) => { + stopCommunityUpdatePolling(communityKey); updateCommunity(community, { communityAddressOrRef, communityKey }); - communityUpdateIntervals.push( - setInterval( + communityUpdatePollers[communityKey] = { + community, + updateInterval: setInterval( () => updateCommunity(community, { communityAddressOrRef, communityKey }), COMMUNITY_UPDATE_INTERVAL_MS, ), - ); + }; +}; + +const stopCommunityUpdatePolling = (communityKey: string) => { + const polling = communityUpdatePollers[communityKey]; + if (!polling) { + return; + } + + clearInterval(polling.updateInterval); + polling.community.removeAllListeners(); + const listenerIndex = listeners.indexOf(polling.community); + if (listenerIndex >= 0) { + listeners.splice(listenerIndex, 1); + } + delete communityUpdatePollers[communityKey]; }; export type CommunitiesState = { @@ -419,6 +442,7 @@ const communitiesStore = createStore( community.on("error", console.log); await community.delete(); + stopCommunityUpdatePolling(communityAddress); await communitiesDatabase.removeItem(communityAddress); log("communitiesStore.deleteCommunity", { communityAddress, community, account }); setState((state: any) => ({ @@ -433,10 +457,9 @@ const originalState = communitiesStore.getState(); // async function because some stores have async init export const resetCommunitiesStore = async () => { pkcGetCommunityPending = {}; - // remove all event listeners listeners.forEach((listener: any) => listener.removeAllListeners()); - communityUpdateIntervals.forEach((updateInterval) => clearInterval(updateInterval)); - communityUpdateIntervals.length = 0; + listeners.length = 0; + Object.keys(communityUpdatePollers).forEach(stopCommunityUpdatePolling); // destroy all component subscriptions to the store communitiesStore.destroy(); // restore original state