diff --git a/src/components/subplebbit-stats/subplebbit-stats.tsx b/src/components/subplebbit-stats/subplebbit-stats.tsx
index 5212d562..d12bbf29 100644
--- a/src/components/subplebbit-stats/subplebbit-stats.tsx
+++ b/src/components/subplebbit-stats/subplebbit-stats.tsx
@@ -1,7 +1,8 @@
import { useParams } from 'react-router-dom';
import { Trans, useTranslation } from 'react-i18next';
-import { useAccountComment, useSubplebbitStats } from '@plebbit/plebbit-react-hooks';
+import { useAccountComment } from '@plebbit/plebbit-react-hooks';
import { useSubplebbitField } from '../../hooks/use-stable-subplebbit';
+import { useStableSubplebbitStats } from '../../hooks/use-stable-subplebbit-stats';
import useSubplebbitsPagesStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits-pages';
import useSubplebbitStatsVisibilityStore from '../../stores/use-subplebbit-stats-visibility-store';
import { useResolvedSubplebbitAddress } from '../../hooks/use-resolved-subplebbit-address';
@@ -19,7 +20,9 @@ const SubplebbitStats = () => {
const address = useSubplebbitField(subplebbitAddress, (subplebbit) => subplebbit?.address);
const createdAt = useSubplebbitField(subplebbitAddress, (subplebbit) => subplebbit?.createdAt);
- let stats = useSubplebbitStats({ subplebbitAddress: address });
+ // Use stable stats hook that doesn't depend on useSubplebbit internally
+ const stats = useStableSubplebbitStats(address);
+
const { hiddenStats, toggleVisibility } = useSubplebbitStatsVisibilityStore();
const isHidden = hiddenStats[address];
@@ -51,13 +54,13 @@ const SubplebbitStats = () => {
}}
/>
{' / '}
}}
/>
|
@@ -66,13 +69,13 @@ const SubplebbitStats = () => {
}}
/>
{' / '}
}}
/>
|
@@ -83,7 +86,7 @@ const SubplebbitStats = () => {
{' / '}
}}
/>
diff --git a/src/hooks/use-stable-subplebbit-stats.ts b/src/hooks/use-stable-subplebbit-stats.ts
new file mode 100644
index 00000000..4a5abde6
--- /dev/null
+++ b/src/hooks/use-stable-subplebbit-stats.ts
@@ -0,0 +1,66 @@
+import { useEffect } from 'react';
+import { useAccount } from '@plebbit/plebbit-react-hooks';
+import { create } from 'zustand';
+import { useSubplebbitField } from './use-stable-subplebbit';
+
+// Store to cache fetched stats and track pending fetches
+interface SubplebbitStatsState {
+ stats: { [address: string]: any };
+ pendingCids: { [cid: string]: boolean };
+ setStats: (address: string, stats: any) => void;
+ setPending: (cid: string, pending: boolean) => void;
+}
+
+const useStableSubplebbitStatsStore = create((set) => ({
+ stats: {},
+ pendingCids: {},
+ setStats: (address, stats) =>
+ set((state) => ({
+ stats: { ...state.stats, [address]: stats },
+ })),
+ setPending: (cid, pending) =>
+ set((state) => ({
+ pendingCids: { ...state.pendingCids, [cid]: pending },
+ })),
+}));
+
+/**
+ * Stable version of useSubplebbitStats that doesn't depend on useSubplebbit internally.
+ * Uses useSubplebbitField to get the statsCid without re-rendering on updatingState changes.
+ */
+export const useStableSubplebbitStats = (subplebbitAddress: string | undefined) => {
+ const account = useAccount();
+
+ // Get statsCid using stable field selector - won't re-render on updatingState changes
+ const statsCid = useSubplebbitField(subplebbitAddress, (sub) => sub?.statsCid);
+
+ const stats = useStableSubplebbitStatsStore((state) => (subplebbitAddress ? state.stats[subplebbitAddress] : undefined));
+ const pendingCids = useStableSubplebbitStatsStore((state) => state.pendingCids);
+ const setStats = useStableSubplebbitStatsStore((state) => state.setStats);
+ const setPending = useStableSubplebbitStatsStore((state) => state.setPending);
+
+ useEffect(() => {
+ if (!subplebbitAddress || !statsCid || !account) {
+ return;
+ }
+
+ // Don't fetch if already fetched or pending
+ if (stats || pendingCids[statsCid]) {
+ return;
+ }
+
+ setPending(statsCid, true);
+
+ account.plebbit
+ .fetchCid(statsCid)
+ .then((fetchedStats: any) => {
+ setStats(subplebbitAddress, JSON.parse(fetchedStats));
+ })
+ .catch((error: any) => {
+ setPending(statsCid, false);
+ console.error('useStableSubplebbitStats fetchCid error', { subplebbitAddress, statsCid, error });
+ });
+ }, [subplebbitAddress, statsCid, account, stats, pendingCids, setStats, setPending]);
+
+ return stats;
+};
diff --git a/src/hooks/use-stable-subplebbit.ts b/src/hooks/use-stable-subplebbit.ts
index 38e0db1f..416a6045 100644
--- a/src/hooks/use-stable-subplebbit.ts
+++ b/src/hooks/use-stable-subplebbit.ts
@@ -69,3 +69,34 @@ export const useSubplebbitField = (subplebbitAddress: string | undefined, sel
return field;
};
+
+/**
+ * Hook to get multiple subplebbits with stable references.
+ * Only re-renders when actual content changes (updatedAt, posts pages), not transient state.
+ *
+ * @param subplebbitAddresses - Array of subplebbit addresses
+ * @returns Array of subplebbit objects
+ */
+export const useStableSubplebbits = (subplebbitAddresses: string[]) => {
+ const subplebbits = useSubplebbitsStore(
+ (state) => subplebbitAddresses.map((address) => state.subplebbits[address]),
+ // Custom equality: only re-render if stable content fields change
+ (prev, next) => {
+ if (prev.length !== next.length) return false;
+ return prev.every((p, i) => {
+ const n = next[i];
+ if (p === n) return true;
+ if (!p || !n) return p === n;
+ // Compare stable content fields only - ignore updatingState, state, clients, etc.
+ return (
+ p.address === n.address &&
+ p.updatedAt === n.updatedAt &&
+ // For PopularThreadsBox: compare posts pages reference (changes when new posts loaded)
+ p.posts?.pages?.hot === n.posts?.pages?.hot
+ );
+ });
+ },
+ );
+
+ return subplebbits;
+};
diff --git a/src/hooks/use-subplebbits-stats.ts b/src/hooks/use-subplebbits-stats.ts
index 075a9e8f..553dbde0 100644
--- a/src/hooks/use-subplebbits-stats.ts
+++ b/src/hooks/use-subplebbits-stats.ts
@@ -1,13 +1,32 @@
import { useEffect, useMemo } from 'react';
-import { useAccount, useSubplebbits } from '@plebbit/plebbit-react-hooks';
+import { useAccount } from '@plebbit/plebbit-react-hooks';
+import useSubplebbitsStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits';
import { create } from 'zustand';
const pendingFetchCid: { [cid: string]: boolean } = {};
+/**
+ * Hook to get stats for multiple subplebbits.
+ * Uses stable store selector to avoid re-renders from updatingState changes.
+ */
const useSubplebbitsStats = (options: any) => {
const { subplebbitAddresses, accountName } = options || {};
const account = useAccount({ accountName });
- const { subplebbits } = useSubplebbits({ subplebbitAddresses });
+
+ // Use stable selector to only get statsCid for each address
+ // This avoids re-renders when only updatingState changes
+ const statsCids = useSubplebbitsStore(
+ (state) =>
+ (subplebbitAddresses || []).map((address: string) => ({
+ address,
+ statsCid: state.subplebbits[address]?.statsCid,
+ })),
+ // Custom equality: only re-render if statsCid values change
+ (prev, next) => {
+ if (prev.length !== next.length) return false;
+ return prev.every((p: any, i: number) => p.address === next[i].address && p.statsCid === next[i].statsCid);
+ },
+ );
const { setSubplebbitStats, subplebbitsStats } = useSubplebbitsStatsStore();
@@ -16,21 +35,21 @@ const useSubplebbitsStats = (options: any) => {
return;
}
- subplebbits.forEach((subplebbit) => {
- if (subplebbit && subplebbit.statsCid && !subplebbitsStats[subplebbit.address] && !pendingFetchCid[subplebbit.statsCid]) {
- pendingFetchCid[subplebbit.statsCid] = true;
+ statsCids.forEach(({ address, statsCid }: { address: string; statsCid: string | undefined }) => {
+ if (statsCid && !subplebbitsStats[address] && !pendingFetchCid[statsCid]) {
+ pendingFetchCid[statsCid] = true;
account.plebbit
- .fetchCid(subplebbit.statsCid)
+ .fetchCid(statsCid)
.then((fetchedStats: any) => {
- setSubplebbitStats(subplebbit.address, JSON.parse(fetchedStats));
+ setSubplebbitStats(address, JSON.parse(fetchedStats));
})
.catch((error: any) => {
- pendingFetchCid[subplebbit.statsCid] = false;
- console.error('Fetching subplebbit stats failed', { subplebbitAddress: subplebbit.address, error });
+ pendingFetchCid[statsCid] = false;
+ console.error('Fetching subplebbit stats failed', { subplebbitAddress: address, error });
});
}
});
- }, [account, subplebbits, setSubplebbitStats, subplebbitsStats, subplebbitAddresses]);
+ }, [account, statsCids, setSubplebbitStats, subplebbitsStats, subplebbitAddresses]);
return useMemo(() => {
return subplebbitAddresses.reduce((acc: any, address: any) => {
diff --git a/src/views/catalog/catalog.tsx b/src/views/catalog/catalog.tsx
index 6b65d5a9..6cc3884d 100644
--- a/src/views/catalog/catalog.tsx
+++ b/src/views/catalog/catalog.tsx
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { Link, useLocation, useNavigationType, useParams } from 'react-router-dom';
import { Trans, useTranslation } from 'react-i18next';
-import { Comment, useAccount, useFeed, useSubplebbit, useBlock, useAccountComments } from '@plebbit/plebbit-react-hooks';
+import { Comment, useAccount, useFeed, useBlock, useAccountComments } from '@plebbit/plebbit-react-hooks';
+import { useSubplebbitField } from '../../hooks/use-stable-subplebbit';
import { Virtuoso, VirtuosoHandle, StateSnapshot } from 'react-virtuoso';
import { getCommentMediaInfo, getHasThumbnail } from '../../lib/utils/media-utils';
import useCatalogFeedRows from '../../hooks/use-catalog-feed-rows';
@@ -479,8 +480,14 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
}
}, [reset, setResetFunction, isVisible]);
- const subplebbit = useSubplebbit({ subplebbitAddress });
- const { error, shortAddress, state, title } = subplebbit || {};
+ // Use stable field selectors to avoid re-renders from updatingState changes
+ const error = useSubplebbitField(subplebbitAddress, (sub) => sub?.error);
+ const shortAddress = useSubplebbitField(subplebbitAddress, (sub) => sub?.shortAddress);
+ const title = useSubplebbitField(subplebbitAddress, (sub) => sub?.title);
+ // Derive state from updatedAt field - if updatedAt exists, state is 'succeeded'
+ const updatedAt = useSubplebbitField(subplebbitAddress, (sub) => sub?.updatedAt);
+ const state = updatedAt ? 'succeeded' : 'fetching-ipns';
+
const { blocked, unblock } = useBlock({ address: subplebbitAddress });
const feedLength = feed.length;
diff --git a/src/views/home/home.tsx b/src/views/home/home.tsx
index 1c60766d..85b093d0 100644
--- a/src/views/home/home.tsx
+++ b/src/views/home/home.tsx
@@ -1,7 +1,6 @@
import { useEffect, useMemo, useRef, FormEvent } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Trans, useTranslation } from 'react-i18next';
-import { useSubplebbits } from '@plebbit/plebbit-react-hooks';
import styles from './home.module.css';
import { useDefaultSubplebbits, useDefaultSubplebbitAddresses } from '../../hooks/use-default-subplebbits';
import useSubplebbitsStats from '../../hooks/use-subplebbits-stats';
@@ -191,7 +190,6 @@ export const HomeLogo = () => {
const Home = () => {
const defaultSubplebbits = useDefaultSubplebbits();
const subplebbitAddresses = useDefaultSubplebbitAddresses();
- const { subplebbits } = useSubplebbits({ subplebbitAddresses });
const { closeDirectoryModal } = useDirectoryModalStore();
useEffect(() => {
@@ -214,7 +212,7 @@ const Home = () => {
-
+
diff --git a/src/views/home/popular-threads-box/popular-threads-box.tsx b/src/views/home/popular-threads-box/popular-threads-box.tsx
index fd6cb0bf..a7a26e9d 100644
--- a/src/views/home/popular-threads-box/popular-threads-box.tsx
+++ b/src/views/home/popular-threads-box/popular-threads-box.tsx
@@ -1,7 +1,8 @@
-import { memo, useMemo } from 'react';
+import { memo, useMemo, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
-import { Comment, Subplebbit } from '@plebbit/plebbit-react-hooks';
+import { Comment, Subplebbit, useAccount } from '@plebbit/plebbit-react-hooks';
+import useSubplebbitsStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits';
import styles from '../home.module.css';
import usePopularPosts from '../../../hooks/use-popular-posts';
import usePopularThreadsOptionsStore from '../../../stores/use-popular-threads-options-store';
@@ -12,6 +13,7 @@ import BoxModal from '../box-modal';
import { MultisubSubplebbit, useDefaultSubplebbits } from '../../../hooks/use-default-subplebbits';
import { getBoardPath } from '../../../lib/utils/route-utils';
import { removeMarkdown } from '../../../lib/utils/post-utils';
+import { useStableSubplebbits } from '../../../hooks/use-stable-subplebbit';
interface PopularThreadProps {
post: Comment;
@@ -67,10 +69,25 @@ const PopularThreadCard = memo(
},
);
-const PopularThreadsBox = ({ multisub, subplebbits }: { multisub: MultisubSubplebbit[]; subplebbits: any }) => {
+// Uses stable subplebbits hook to avoid re-renders from updatingState changes
+const PopularThreadsBox = ({ multisub, subplebbitAddresses }: { multisub: MultisubSubplebbit[]; subplebbitAddresses: string[] }) => {
const { t } = useTranslation();
const { showWorksafeContentOnly, showNsfwContentOnly } = usePopularThreadsOptionsStore();
+ const account = useAccount();
+ const addSubplebbitToStore = useSubplebbitsStore((state) => state.addSubplebbitToStore);
+
+ // Trigger fetching subplebbits (same as useSubplebbits does internally)
+ useEffect(() => {
+ if (!account || !subplebbitAddresses) return;
+ for (const address of subplebbitAddresses) {
+ addSubplebbitToStore(address, account).catch(() => {});
+ }
+ }, [subplebbitAddresses?.toString(), account?.id]);
+
+ // Use stable hook that only re-renders when actual content changes
+ const subplebbits = useStableSubplebbits(subplebbitAddresses);
+
const getFilteredSubplebbits = () => {
if (showWorksafeContentOnly) {
return subplebbits.filter((sub: Subplebbit) => {
diff --git a/src/views/post/post.tsx b/src/views/post/post.tsx
index a4edb3e4..20bc8e9e 100644
--- a/src/views/post/post.tsx
+++ b/src/views/post/post.tsx
@@ -71,6 +71,7 @@ const PostPage = () => {
const subplebbit = useSubplebbit({ subplebbitAddress });
const { error: subplebbitError, shortAddress, title } = subplebbit || {};
+
const defaultSubplebbits = useDefaultSubplebbits();
// if the comment is a reply, return the post comment instead, then the reply will be highlighted in the thread