Skip to content
Merged
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,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: [
Expand Down
3 changes: 3 additions & 0 deletions llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,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: [
Expand Down
6 changes: 6 additions & 0 deletions src/lib/pkc-js/pkc-js-mock-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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");
Expand Down
6 changes: 1 addition & 5 deletions src/lib/pkc-js/pkc-js-mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
}
Expand Down Expand Up @@ -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");
Expand Down
22 changes: 14 additions & 8 deletions src/stores/accounts/accounts-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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; reset modules and re-import to exercise early-return branch
vi.resetModules();
// @ts-ignore
expect(window.BITSOCIAL_REACT_HOOKS_ACCOUNTS_STORE_INITIALIZED_ONCE).toBe(true);

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([]);
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");
}
});
});
});
137 changes: 136 additions & 1 deletion src/stores/communities/communities-store.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand All @@ -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" });
Expand Down Expand Up @@ -186,6 +228,87 @@ 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("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;
Expand Down Expand Up @@ -387,6 +510,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),
Expand Down
71 changes: 66 additions & 5 deletions src/stores/communities/communities-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,19 @@ import {
getPkcGetCommunity,
} from "../../lib/pkc-compat";

export const COMMUNITY_UPDATE_INTERVAL_MS = 15 * 60 * 1000;

let pkcGetCommunityPending: { [key: string]: boolean } = {};
// Key pollers by community so delete/reset can stop the matching live instance.
const communityUpdatePollers: {
[communityKey: string]: {
community: any;
updateInterval: ReturnType<typeof setInterval>;
};
} = {};

// reset all event listeners in between tests
const listeners: any = [];

const COMMUNITY_ERROR_UPDATE_GRACE_MS = 1000;
const pendingCommunityErrorTimers: {
Expand All @@ -46,8 +58,56 @@ 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,
{
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 },
) => {
stopCommunityUpdatePolling(communityKey);
updateCommunity(community, { communityAddressOrRef, communityKey });
communityUpdatePollers[communityKey] = {
community,
updateInterval: setInterval(
() => updateCommunity(community, { communityAddressOrRef, communityKey }),
COMMUNITY_UPDATE_INTERVAL_MS,
),
};
};

const stopCommunityUpdatePolling = (communityKey: string) => {
const polling = communityUpdatePollers[communityKey];
if (!polling) {
return;
}

clearPendingCommunityErrors(communityKey);
clearInterval(polling.updateInterval);
polling.community.removeAllListeners();
const listenerIndex = listeners.indexOf(polling.community);
if (listenerIndex >= 0) {
listeners.splice(listenerIndex, 1);
}
delete communityUpdatePollers[communityKey];
};

const clearPendingCommunityErrors = (communityKey: string) => {
pendingCommunityErrorTimers[communityKey]?.forEach((timeout) => clearTimeout(timeout));
Expand Down Expand Up @@ -285,9 +345,7 @@ const communitiesStore = createStore<CommunitiesState>(
);

listeners.push(community);
community
.update()
.catch((error: unknown) => log.trace("community.update error", { community, error }));
startCommunityUpdatePolling(community, { communityAddressOrRef, communityKey });
} finally {
pkcGetCommunityPending[pendingKey] = false;
}
Expand Down Expand Up @@ -429,6 +487,7 @@ const communitiesStore = createStore<CommunitiesState>(
community.on("error", console.log);

await community.delete();
stopCommunityUpdatePolling(communityAddress);
await communitiesDatabase.removeItem(communityAddress);
log("communitiesStore.deleteCommunity", { communityAddress, community, account });
setState((state: any) => ({
Expand All @@ -446,6 +505,8 @@ export const resetCommunitiesStore = async () => {
Object.keys(pendingCommunityErrorTimers).forEach(clearPendingCommunityErrors);
// remove all event listeners
listeners.forEach((listener: any) => listener.removeAllListeners());
listeners.length = 0;
Object.keys(communityUpdatePollers).forEach(stopCommunityUpdatePolling);
// destroy all component subscriptions to the store
communitiesStore.destroy();
// restore original state
Expand Down
Loading