diff --git a/README.md b/README.md index f4f8c766..66696ef9 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,7 @@ useValidateComment({comment: Comment, validateReplies?: boolean}): {valid: boole #### Communities Hooks ``` -useCommunity({community: {name?: string, publicKey?: string}, onlyIfCached?: boolean}): Community +useCommunity({community: {name?: string, publicKey?: string}, onlyIfCached?: boolean}): Community & {syncState: "initializing" | "loading" | "retrying" | "succeeded" | "failed" | "stopped", hasCachedData: boolean, lastFetchAttemptAt: number | undefined, lastSuccessfulFetchAt: number | undefined} useCommunities({communities?: CommunityIdentifier[], onlyIfCached?: boolean}): {communities: Communities[]} useCommunityStats({community: {name?: string, publicKey?: string}, onlyIfCached?: boolean}): CommunityStats useResolvedCommunityAddress({communityAddress: string, cache: boolean}): {resolvedAddress: string | undefined} // use {cache: false} when checking the user's own community address @@ -139,6 +139,13 @@ Pass `{ publicKey, name }` when you have both so `pkc-js` can fetch through the `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`. +`useCommunity().state` remains `succeeded` when cached community data is available, even +while a refresh is running. Use `syncState` for the current refresh lifecycle: +`loading` covers active name, IPNS, and IPFS work, while `retrying` means a transient +failure is being retried. `lastFetchAttemptAt` and `lastSuccessfulFetchAt` are Unix +timestamps in seconds. A successful fetch only proves that a valid community record was +reachable; it does not prove that the community operator is currently online. + #### Authors Hooks ``` @@ -409,6 +416,12 @@ const { authorComments, lastCommentCid, hasMore, loadMore } = useAuthorComments( ```jsx const community = useCommunity({ community: { name: communityAddress, publicKey: communityPublicKey } }); +const { + syncState, + hasCachedData, + lastFetchAttemptAt, + lastSuccessfulFetchAt, +} = community; const communityStats = useCommunityStats({ community: { name: communityAddress, publicKey: communityPublicKey }, }); diff --git a/llms-full.txt b/llms-full.txt index 6490398d..79d1b04a 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -159,7 +159,7 @@ useValidateComment({comment: Comment, validateReplies?: boolean}): {valid: boole #### Communities Hooks ``` -useCommunity({community: {name?: string, publicKey?: string}, onlyIfCached?: boolean}): Community +useCommunity({community: {name?: string, publicKey?: string}, onlyIfCached?: boolean}): Community & {syncState: "initializing" | "loading" | "retrying" | "succeeded" | "failed" | "stopped", hasCachedData: boolean, lastFetchAttemptAt: number | undefined, lastSuccessfulFetchAt: number | undefined} useCommunities({communities?: CommunityIdentifier[], onlyIfCached?: boolean}): {communities: Communities[]} useCommunityStats({community: {name?: string, publicKey?: string}, onlyIfCached?: boolean}): CommunityStats useResolvedCommunityAddress({communityAddress: string, cache: boolean}): {resolvedAddress: string | undefined} // use {cache: false} when checking the user's own community address @@ -169,6 +169,13 @@ Pass `{ publicKey, name }` when you have both so `pkc-js` can fetch through the `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`. +`useCommunity().state` remains `succeeded` when cached community data is available, even +while a refresh is running. Use `syncState` for the current refresh lifecycle: +`loading` covers active name, IPNS, and IPFS work, while `retrying` means a transient +failure is being retried. `lastFetchAttemptAt` and `lastSuccessfulFetchAt` are Unix +timestamps in seconds. A successful fetch only proves that a valid community record was +reachable; it does not prove that the community operator is currently online. + #### Authors Hooks ``` @@ -439,6 +446,12 @@ const { authorComments, lastCommentCid, hasMore, loadMore } = useAuthorComments( ```jsx const community = useCommunity({ community: { name: communityAddress, publicKey: communityPublicKey } }); +const { + syncState, + hasCachedData, + lastFetchAttemptAt, + lastSuccessfulFetchAt, +} = community; const communityStats = useCommunityStats({ community: { name: communityAddress, publicKey: communityPublicKey }, }); @@ -3181,7 +3194,11 @@ 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.28](https://github.com/bitsocialnet/bitsocial-react-hooks/compare/v0.1.27...v0.1.28) (2026-07-10) +## [0.1.30](https://github.com/bitsocialnet/bitsocial-react-hooks/compare/v0.1.29...v0.1.30) (2026-07-14) + + + +## [0.1.29](https://github.com/bitsocialnet/bitsocial-react-hooks/compare/v0.1.28...v0.1.29) (2026-07-14) diff --git a/src/hooks/communities.test.ts b/src/hooks/communities.test.ts index 2be79b5d..e6ffdf84 100644 --- a/src/hooks/communities.test.ts +++ b/src/hooks/communities.test.ts @@ -152,6 +152,10 @@ describe("communities", () => { testUtils.createWaitFor(rendered); rendered.rerender({ community: { name: "community address 1" }, onlyIfCached: true }); + expect(rendered.result.current.syncState).toBe("stopped"); + expect(rendered.result.current.hasCachedData).toBe(false); + expect(rendered.result.current.lastFetchAttemptAt).toBeUndefined(); + expect(rendered.result.current.lastSuccessfulFetchAt).toBeUndefined(); // TODO: find better way to wait await new Promise((r) => setTimeout(r, 20)); // community not added to store @@ -240,21 +244,71 @@ describe("communities", () => { ); }); - test("has updating state", async () => { - const rendered = renderHook((communityAddress) => - useCommunity({ community: toCommunity(communityAddress) }), - ); - const waitFor = testUtils.createWaitFor(rendered); - rendered.rerender("community address"); + test("exposes community sync lifecycle separately from cached data state", async () => { + const communityUpdate = Community.prototype.update; + const updatingCommunities: Community[] = []; + let now = 1_800_000_000_000; + const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now); + Community.prototype.update = async function () { + updatingCommunities.push(this); + }; - await waitFor( - () => - rendered.result.current.state === "fetching-ipns" || - rendered.result.current.state === "succeeded", - ); + try { + const rendered = renderHook((communityAddress) => + useCommunity({ community: toCommunity(communityAddress) }), + ); + const waitFor = testUtils.createWaitFor(rendered); + rendered.rerender("community address"); - await waitFor(() => rendered.result.current.state === "succeeded"); - expect(rendered.result.current.state).toBe("succeeded"); + expect(rendered.result.current.syncState).toBe("initializing"); + expect(rendered.result.current.hasCachedData).toBe(false); + await waitFor(() => updatingCommunities.length > 0); + expect(rendered.result.current.lastFetchAttemptAt).toBeUndefined(); + + now += 1_000; + updatingCommunities[0].emit("updatingstatechange", "fetching-ipns"); + await waitFor(() => rendered.result.current.syncState === "loading"); + expect(rendered.result.current.lastFetchAttemptAt).toBe(1_800_000_001); + + now += 1_000; + updatingCommunities[0].emit("updatingstatechange", "fetching-ipfs"); + await act(async () => {}); + expect(rendered.result.current.syncState).toBe("loading"); + expect(rendered.result.current.lastFetchAttemptAt).toBe(1_800_000_001); + + updatingCommunities[0].emit("updatingstatechange", "waiting-retry"); + await waitFor(() => rendered.result.current.syncState === "retrying"); + + now += 1_000; + updatingCommunities[0].emit("updatingstatechange", "fetching-ipns"); + await waitFor(() => rendered.result.current.syncState === "loading"); + expect(rendered.result.current.lastFetchAttemptAt).toBe(1_800_000_003); + + updatingCommunities[0].updatedAt = 1_799_999_000; + updatingCommunities[0].emit("update", updatingCommunities[0]); + now += 1_000; + updatingCommunities[0].emit("updatingstatechange", "succeeded"); + await waitFor(() => rendered.result.current.syncState === "succeeded"); + expect(rendered.result.current.state).toBe("succeeded"); + expect(rendered.result.current.hasCachedData).toBe(true); + expect(rendered.result.current.lastSuccessfulFetchAt).toBe(1_800_000_004); + + updatingCommunities[0].emit("updatingstatechange", "waiting-retry"); + await waitFor(() => rendered.result.current.syncState === "retrying"); + expect(rendered.result.current.state).toBe("succeeded"); + + updatingCommunities[0].emit("updatingstatechange", "publishing-ipns"); + await waitFor(() => rendered.result.current.syncState === "loading"); + + updatingCommunities[0].emit("updatingstatechange", "stopped"); + await waitFor(() => rendered.result.current.syncState === "stopped"); + + updatingCommunities[0].emit("updatingstatechange", "failed"); + await waitFor(() => rendered.result.current.syncState === "failed"); + } finally { + Community.prototype.update = communityUpdate; + nowSpy.mockRestore(); + } }); test("overlays local community edit summary from the active account", async () => { @@ -379,6 +433,7 @@ describe("communities", () => { expect(rendered.result.current.error.message).toBe("pkc.createCommunity error"); expect(rendered.result.current.errors[0].message).toBe("pkc.createCommunity error"); expect(rendered.result.current.errors.length).toBe(1); + expect(rendered.result.current.syncState).toBe("failed"); // restore mock PKC.prototype.createCommunity = createCommunity; diff --git a/src/hooks/communities.ts b/src/hooks/communities.ts index 8e69f683..cec83309 100644 --- a/src/hooks/communities.ts +++ b/src/hooks/communities.ts @@ -80,6 +80,7 @@ export function useCommunity(options?: UseCommunityOptions): UseCommunityResult const storedCommunity = useCommunitiesStore((state: any) => state.communities[communityKey]); const addCommunityToStore = useCommunitiesStore((state: any) => state.addCommunityToStore); const errors = useCommunitiesStore((state: any) => state.errors[communityKey]); + const syncStatus = useCommunitiesStore((state: any) => state.syncStatuses[communityKey]); const communityEditSummary = useAccountsStore((state: any) => { const accountEditsSummaries = state.accountsEditsSummaries[accountId] || {}; const candidateCommunityKeys = [ @@ -169,10 +170,14 @@ export function useCommunity(options?: UseCommunityOptions): UseCommunityResult () => ({ ...mergedCommunity, state, + syncState: syncStatus?.syncState || (onlyIfCached ? "stopped" : "initializing"), + hasCachedData: typeof mergedCommunity?.updatedAt === "number", + lastFetchAttemptAt: syncStatus?.lastFetchAttemptAt, + lastSuccessfulFetchAt: syncStatus?.lastSuccessfulFetchAt, error: errors?.[errors.length - 1], errors: errors || [], }), - [mergedCommunity, communityKey, errors], + [mergedCommunity, communityKey, errors, onlyIfCached, syncStatus], ); } diff --git a/src/stores/communities/communities-store.test.ts b/src/stores/communities/communities-store.test.ts index 236093c0..e96de8e6 100644 --- a/src/stores/communities/communities-store.test.ts +++ b/src/stores/communities/communities-store.test.ts @@ -31,6 +31,7 @@ describe("communities store", () => { const { result } = renderHook(() => communitiesStore.getState()); expect(result.current.communities).toEqual({}); expect(result.current.errors).toEqual({}); + expect(result.current.syncStatuses).toEqual({}); expect(typeof result.current.addCommunityToStore).toBe("function"); }); @@ -367,6 +368,7 @@ describe("communities store", () => { expect(communitiesStore.getState().errors[address]).toHaveLength(1); expect(communitiesStore.getState().errors[address][0].message).toBe("create failed"); + expect(communitiesStore.getState().syncStatuses[address]?.syncState).toBe("failed"); mockAccount.pkc.createCommunity = createOrig; }); @@ -699,6 +701,9 @@ describe("communities store", () => { expect(community.address).toBeDefined(); expect(communitiesStore.getState().communities[community.address]?.title).toBe("created title"); + communitiesStore.setState((state) => ({ + errors: { ...state.errors, [community.address]: [new Error("stale error")] }, + })); await act(async () => { await communitiesStore @@ -713,6 +718,8 @@ describe("communities store", () => { }); expect(communitiesStore.getState().communities[community.address]).toBeUndefined(); + expect(communitiesStore.getState().errors[community.address]).toBeUndefined(); + expect(communitiesStore.getState().syncStatuses[community.address]).toBeUndefined(); }); test("clientsOnStateChange with chainTicker branch", async () => { @@ -745,6 +752,37 @@ describe("communities store", () => { (utils.default as any).clientsOnStateChange = origClientsOnStateChange; }); + test("clientsOnStateChange without chainTicker updates the client state", async () => { + const address = "client-state-address"; + let storedCb: ((...args: any[]) => void) | null = null; + + const utils = await import("../../lib/utils"); + const origClientsOnStateChange = utils.default.clientsOnStateChange; + (utils.default as any).clientsOnStateChange = (_clients: any, cb: any) => { + storedCb = () => cb("fetching-ipns", "type", "url"); + }; + + await act(async () => { + await communitiesStore.getState().addCommunityToStore(address, mockAccount); + }); + + communitiesStore.setState((state: any) => ({ + communities: { + ...state.communities, + [address]: { + ...state.communities[address], + clients: { type: {} }, + }, + }, + })); + storedCb!(); + expect(communitiesStore.getState().communities[address]?.clients?.type?.url).toEqual({ + state: "fetching-ipns", + }); + + (utils.default as any).clientsOnStateChange = origClientsOnStateChange; + }); + test("clientsOnStateChange returns {} when community missing and chainTicker provided", async () => { const address = "chain-missing-address"; let storedCb: ((...args: any[]) => void) | null = null; diff --git a/src/stores/communities/communities-store.ts b/src/stores/communities/communities-store.ts index da2b5071..e03e17d9 100644 --- a/src/stores/communities/communities-store.ts +++ b/src/stores/communities/communities-store.ts @@ -11,6 +11,7 @@ import { Communities, Account, CommunityIdentifier, + CommunitySyncState, CreateCommunityOptions, } from "../../types"; import utils from "../../lib/utils"; @@ -45,6 +46,48 @@ const pendingCommunityErrorTimers: { [communityKey: string]: ReturnType[]; } = {}; +interface CommunitySyncStatus { + syncState: CommunitySyncState; + lastFetchAttemptAt?: number; + lastSuccessfulFetchAt?: number; +} + +const getNowSeconds = () => Math.floor(Date.now() / 1000); + +const normalizeCommunitySyncState = (updatingState: string): CommunitySyncState => { + if (updatingState === "waiting-retry") { + return "retrying"; + } + if (updatingState === "succeeded" || updatingState === "failed" || updatingState === "stopped") { + return updatingState; + } + return "loading"; +}; + +const updateCommunitySyncStatus = ( + setState: Function, + communityKey: string, + syncState: CommunitySyncState, +) => { + setState((state: CommunitiesState) => { + const previousStatus = state.syncStatuses[communityKey]; + const timestamp = getNowSeconds(); + const shouldRecordAttempt = syncState === "loading" && previousStatus?.syncState !== "loading"; + return { + ...state, + syncStatuses: { + ...state.syncStatuses, + [communityKey]: { + ...previousStatus, + syncState, + ...(shouldRecordAttempt ? { lastFetchAttemptAt: timestamp } : undefined), + ...(syncState === "succeeded" ? { lastSuccessfulFetchAt: timestamp } : undefined), + }, + }, + }; + }); +}; + const createCommunityWithLookupFallback = async ( pkc: any, communityLookupOptions: { address?: string; name?: string; publicKey?: string }, @@ -170,6 +213,7 @@ const scheduleCommunityError = (setState: Function, communityKey: string, error: export type CommunitiesState = { communities: Communities; errors: { [communityAddress: string]: Error[] }; + syncStatuses: { [communityAddress: string]: CommunitySyncStatus }; addCommunityToStore: Function; refreshCommunity: Function; editCommunity: Function; @@ -181,6 +225,7 @@ const communitiesStore = createStore( (setState: Function, getState: Function) => ({ communities: {}, errors: {}, + syncStatuses: {}, async addCommunityToStore( communityAddressOrRef: string | CommunityIdentifier, @@ -210,6 +255,7 @@ const communitiesStore = createStore( // start trying to get community pkcGetCommunityPending[pendingKey] = true; + updateCommunitySyncStatus(setState, communityKey, "initializing"); let errorGettingCommunity: any; try { // try to find community in owner communities @@ -349,6 +395,11 @@ const communitiesStore = createStore( }); community.on("updatingstatechange", (updatingState: string) => { + updateCommunitySyncStatus( + setState, + communityKey, + normalizeCommunitySyncState(updatingState), + ); setState((state: CommunitiesState) => ({ communities: { ...state.communities, @@ -390,6 +441,9 @@ const communitiesStore = createStore( listeners.push(community); startCommunityUpdatePolling(community, { communityAddressOrRef, communityKey }); + } catch (error) { + updateCommunitySyncStatus(setState, communityKey, "failed"); + throw error; } finally { pkcGetCommunityPending[pendingKey] = false; } @@ -534,9 +588,17 @@ const communitiesStore = createStore( stopCommunityUpdatePolling(communityAddress); await communitiesDatabase.removeItem(communityAddress); log("communitiesStore.deleteCommunity", { communityAddress, community, account }); - setState((state: any) => ({ - communities: { ...state.communities, [communityAddress]: undefined }, - })); + setState((state: CommunitiesState) => { + const syncStatuses = { ...state.syncStatuses }; + delete syncStatuses[communityAddress]; + const errors = { ...state.errors }; + delete errors[communityAddress]; + return { + communities: { ...state.communities, [communityAddress]: undefined }, + errors, + syncStatuses, + }; + }); }, }), ); diff --git a/src/types.ts b/src/types.ts index f5e9a721..0fc7d008 100644 --- a/src/types.ts +++ b/src/types.ts @@ -199,11 +199,24 @@ export interface UseEditedCommentResult extends Result { } // useCommunity(options): result +export type CommunitySyncState = + | "initializing" + | "loading" + | "retrying" + | "succeeded" + | "failed" + | "stopped"; + export interface UseCommunityOptions extends Options { community?: CommunityIdentifier; onlyIfCached?: boolean; } -export interface UseCommunityResult extends Result, Community {} +export interface UseCommunityResult extends Result, Community { + syncState: CommunitySyncState; + hasCachedData: boolean; + lastFetchAttemptAt: number | undefined; + lastSuccessfulFetchAt: number | undefined; +} // useCommunities(options): result export interface UseCommunitiesOptions extends Options {