diff --git a/src/components/board-header/board-header.tsx b/src/components/board-header/board-header.tsx
index c7a4adbc..a2123a50 100644
--- a/src/components/board-header/board-header.tsx
+++ b/src/components/board-header/board-header.tsx
@@ -1,9 +1,11 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation, useParams, useNavigate } from 'react-router-dom';
-import { useAccount, useAccountComment } from '@plebbit/plebbit-react-hooks';
-import Plebbit from '@plebbit/plebbit-js';
+import { useAccountComment } from '@plebbit/plebbit-react-hooks';
+import useAccountsStore from '@plebbit/plebbit-react-hooks/dist/stores/accounts';
import useSubplebbitsStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits';
+import Plebbit from '@plebbit/plebbit-js';
+import { useStableSubplebbit } from '../../hooks/use-stable-subplebbit';
import { isAllView, isSubscriptionsView, isModView } from '../../lib/utils/view-utils';
import styles from './board-header.module.css';
import { useMultisubMetadata, useDefaultSubplebbits } from '../../hooks/use-default-subplebbits';
@@ -21,6 +23,26 @@ const ImageBanner = () => {
return
;
};
+// Separate component for offline indicator to isolate rerenders from updatingState
+// Only this component will rerender when updatingState changes, not the whole BoardHeader
+const OfflineIndicator = ({ subplebbitAddress }: { subplebbitAddress: string | undefined }) => {
+ // Subscribe to full subplebbit including transient state for offline detection
+ const subplebbit = useSubplebbitsStore((state) => (subplebbitAddress ? state.subplebbits[subplebbitAddress] : undefined));
+ const { isOffline, isOnlineStatusLoading, offlineIconClass, offlineTitle } = useIsSubplebbitOffline(subplebbit);
+
+ if (!isOffline && !isOnlineStatusLoading) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+ );
+};
+
const BoardHeader = () => {
const { t } = useTranslation();
const location = useLocation();
@@ -33,9 +55,9 @@ const BoardHeader = () => {
const resolvedAddress = useResolvedSubplebbitAddress();
const subplebbitAddress = resolvedAddress || accountComment?.subplebbitAddress;
- const subplebbit = useSubplebbitsStore((state) => state.subplebbits[subplebbitAddress]);
-
- const { address, shortAddress } = subplebbit || {};
+ // Use stable subplebbit for display fields to avoid rerenders from updatingState
+ const stableSubplebbit = useStableSubplebbit(subplebbitAddress);
+ const { address, shortAddress } = stableSubplebbit || {};
const multisubMetadata = useMultisubMetadata();
const defaultSubplebbits = useDefaultSubplebbits();
@@ -43,21 +65,23 @@ const BoardHeader = () => {
// Find matching subplebbit from default list to get its title
const defaultSubplebbit = subplebbitAddress ? defaultSubplebbits.find((s) => s.address === subplebbitAddress) : null;
- const account = useAccount() || {};
- const subscriptions = account?.subscriptions || [];
- const subscriptionsSubtitle = t('subscriptions_subtitle', { count: subscriptions?.length || 0 });
+ // Use accounts store with selector to only subscribe to subscriptions count
+ const subscriptionsCount = useAccountsStore((state) => {
+ const activeAccountId = state.activeAccountId;
+ const activeAccount = activeAccountId ? state.accounts[activeAccountId] : undefined;
+ return activeAccount?.subscriptions?.length || 0;
+ });
+ const subscriptionsSubtitle = t('subscriptions_subtitle', { count: subscriptionsCount });
const title = isInAllView
? multisubMetadata?.title || '/all/ - 5chan Directories'
: isInSubscriptionsView
- ? '/subs/ - Subscriptions'
- : isInModView
- ? _.startCase(t('boards_you_moderate'))
- : defaultSubplebbit?.title || subplebbit?.title;
+ ? '/subs/ - Subscriptions'
+ : isInModView
+ ? _.startCase(t('boards_you_moderate'))
+ : defaultSubplebbit?.title || stableSubplebbit?.title;
const subtitle = isInAllView ? '' : isInSubscriptionsView ? subscriptionsSubtitle : isInModView ? '/mod/' : `${address || subplebbitAddress || ''}`;
- const { isOffline, isOnlineStatusLoading, offlineIconClass, offlineTitle } = useIsSubplebbitOffline(subplebbit);
-
return (
{!useIsMobile() && (
@@ -72,13 +96,7 @@ const BoardHeader = () => {
? shortAddress.slice(0, -4)
: shortAddress
: subplebbitAddress && Plebbit.getShortAddress({ address: subplebbitAddress }))}
- {(isOffline || isOnlineStatusLoading) && !isInAllView && !isInSubscriptionsView && !isInModView && (
-
-
-
-
-
- )}
+ {!isInAllView && !isInSubscriptionsView && !isInModView && }
{isInSubscriptionsView ? (
diff --git a/src/components/catalog-row/catalog-row.tsx b/src/components/catalog-row/catalog-row.tsx
index 440e65ec..a835949b 100644
--- a/src/components/catalog-row/catalog-row.tsx
+++ b/src/components/catalog-row/catalog-row.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useRef, useState } from 'react';
+import { memo, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { Link, useLocation, useParams } from 'react-router-dom';
@@ -109,202 +109,206 @@ export const CatalogPostMedia = ({ cid, commentMediaInfo, linkWidth, linkHeight
);
};
-const CatalogPost = ({ post }: { post: Comment }) => {
- const { t } = useTranslation();
- const { author, cid, content, link, linkHeight, linkWidth, locked, pinned, replyCount, spoiler, subplebbitAddress, timestamp, title, thumbnailUrl } = post || {};
- const linkCount = useCountLinksInReplies(post);
+// Memoize CatalogPost to prevent rerenders when parent rerenders due to updatingState
+const CatalogPost = memo(
+ ({ post }: { post: Comment }) => {
+ const { t } = useTranslation();
+ const { author, cid, content, link, linkHeight, linkWidth, locked, pinned, replyCount, spoiler, subplebbitAddress, timestamp, title, thumbnailUrl } = post || {};
+ const linkCount = useCountLinksInReplies(post);
- const commentMediaInfo = useCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
- const hasThumbnail = getHasThumbnail(commentMediaInfo, link);
+ const commentMediaInfo = useCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
+ const hasThumbnail = getHasThumbnail(commentMediaInfo, link);
- const { hidden } = useHide({ cid });
+ const { hidden } = useHide({ cid });
- const location = useLocation();
- const params = useParams();
- const isInAllView = isAllView(location.pathname);
- const isInSubscriptionsView = isSubscriptionsView(location.pathname, params);
- const defaultSubplebbits = useDefaultSubplebbits();
- const boardPath = subplebbitAddress ? getBoardPath(subplebbitAddress, defaultSubplebbits) : '';
- const postMenuProps = useMemo(() => selectPostMenuProps(post), [post]);
+ const location = useLocation();
+ const params = useParams();
+ const isInAllView = isAllView(location.pathname);
+ const isInSubscriptionsView = isSubscriptionsView(location.pathname, params);
+ const defaultSubplebbits = useDefaultSubplebbits();
+ const boardPath = subplebbitAddress ? getBoardPath(subplebbitAddress, defaultSubplebbits) : '';
+ const postMenuProps = useMemo(() => selectPostMenuProps(post), [post]);
- const postLink = boardPath ? `/${boardPath}/thread/${cid}` : `/thread/${cid}`;
+ const postLink = boardPath ? `/${boardPath}/thread/${cid}` : `/thread/${cid}`;
- const threadIcons = (
-
- {pinned && }
- {locked && }
-
- );
+ const threadIcons = (
+
+ {pinned && }
+ {locked && }
+
+ );
- const [hoveredCid, setHoveredCid] = useState
(null);
- const [showPortal, setShowPortal] = useState(false);
- const placementRef = useRef('right-start');
- const timeoutRef = useRef(null);
+ const [hoveredCid, setHoveredCid] = useState(null);
+ const [showPortal, setShowPortal] = useState(false);
+ const placementRef = useRef('right-start');
+ const timeoutRef = useRef(null);
- const windowWidth = useWindowWidth();
+ const windowWidth = useWindowWidth();
- const { refs, floatingStyles, update } = useFloating({
- open: showPortal,
- placement: placementRef.current,
- middleware: [
- offset({ mainAxis: 5 }),
- size({
- apply({ elements }) {
- const referenceElement = refs.reference.current;
- if (referenceElement) {
- const availableWidthToTheRight = windowWidth - (referenceElement.getBoundingClientRect().left + referenceElement.getBoundingClientRect().width);
- const availableWidthToTheLeft = referenceElement.getBoundingClientRect().left;
- const minWidth = windowWidth * 0.25;
+ const { refs, floatingStyles, update } = useFloating({
+ open: showPortal,
+ placement: placementRef.current,
+ middleware: [
+ offset({ mainAxis: 5 }),
+ size({
+ apply({ elements }) {
+ const referenceElement = refs.reference.current;
+ if (referenceElement) {
+ const availableWidthToTheRight = windowWidth - (referenceElement.getBoundingClientRect().left + referenceElement.getBoundingClientRect().width);
+ const availableWidthToTheLeft = referenceElement.getBoundingClientRect().left;
+ const minWidth = windowWidth * 0.25;
- if (availableWidthToTheRight >= minWidth) {
- placementRef.current = 'right-start';
- elements.floating.style.maxWidth = `${availableWidthToTheRight - 40}px`;
- } else if (availableWidthToTheLeft >= minWidth) {
- placementRef.current = 'left-start';
- elements.floating.style.maxWidth = `${availableWidthToTheLeft - 25}px`;
- } else if (availableWidthToTheRight > availableWidthToTheLeft) {
- placementRef.current = 'right-start';
- elements.floating.style.maxWidth = `${availableWidthToTheRight - 40}px`;
- } else {
- placementRef.current = 'left-start';
- elements.floating.style.maxWidth = `${availableWidthToTheLeft - 25}px`;
+ if (availableWidthToTheRight >= minWidth) {
+ placementRef.current = 'right-start';
+ elements.floating.style.maxWidth = `${availableWidthToTheRight - 40}px`;
+ } else if (availableWidthToTheLeft >= minWidth) {
+ placementRef.current = 'left-start';
+ elements.floating.style.maxWidth = `${availableWidthToTheLeft - 25}px`;
+ } else if (availableWidthToTheRight > availableWidthToTheLeft) {
+ placementRef.current = 'right-start';
+ elements.floating.style.maxWidth = `${availableWidthToTheRight - 40}px`;
+ } else {
+ placementRef.current = 'left-start';
+ elements.floating.style.maxWidth = `${availableWidthToTheLeft - 25}px`;
+ }
}
- }
- },
- }),
- ],
- whileElementsMounted: autoUpdate,
- });
+ },
+ }),
+ ],
+ whileElementsMounted: autoUpdate,
+ });
- useEffect(() => {
- update();
- }, [update, windowWidth]);
+ useEffect(() => {
+ update();
+ }, [update, windowWidth]);
- const { replies } = useReplies({ comment: post });
- const lastReply = replies?.length > 0 ? replies[replies.length - 1] : null;
+ const { replies } = useReplies({ comment: post, flat: true });
+ const lastReply = replies?.length > 0 ? replies[replies.length - 1] : null;
- const { isCommentAuthorMod: isCatalogPostAuthorMod, commentAuthorRole: catalogPostAuthorRole } = useEditCommentPrivileges({
- commentAuthorAddress: author?.address,
- subplebbitAddress,
- });
- const { isCommentAuthorMod: isLastReplyAuthorMod, commentAuthorRole: lastReplyAuthorRole } = useEditCommentPrivileges({
- commentAuthorAddress: lastReply?.author?.address,
- subplebbitAddress,
- });
+ const { isCommentAuthorMod: isCatalogPostAuthorMod, commentAuthorRole: catalogPostAuthorRole } = useEditCommentPrivileges({
+ commentAuthorAddress: author?.address,
+ subplebbitAddress,
+ });
+ const { isCommentAuthorMod: isLastReplyAuthorMod, commentAuthorRole: lastReplyAuthorRole } = useEditCommentPrivileges({
+ commentAuthorAddress: lastReply?.author?.address,
+ subplebbitAddress,
+ });
- const postContent = (
-
- {hidden ? (
- ({t('hidden')})
- ) : (
- <>
- {title && (
-
- {title}
- {content ? ': ' : ''}
-
- )}
- {content && }
- >
- )}
-
- );
-
- const { imageSize, showOPComment } = useCatalogStyleStore();
- const maxWidth = imageSize === 'Large' ? '250px' : '150px';
- const maxHeight = imageSize === 'Large' ? '250px' : '150px';
- const CSSProperties = {
- '--maxWidth': maxWidth,
- '--maxHeight': maxHeight,
- } as React.CSSProperties;
-
- const isTextOnlyThread = !hasThumbnail;
-
- return (
- <>
-
-
setHoveredCid(cid)} onMouseLeave={() => setHoveredCid(null)}>
- {hidden ? (
-
-
-
- ) : hasThumbnail ? (
- <>
- {shouldShowSnow() && hasThumbnail &&

}
-
-
(timeoutRef.current = setTimeout(() => setShowPortal(true), 250))}
- onMouseLeave={() => {
- setShowPortal(false);
- if (timeoutRef.current) {
- clearTimeout(timeoutRef.current);
- timeoutRef.current = null;
- }
- }}
- >
- {threadIcons}
- {spoiler ? (
-

- ) : (
-
- )}
-
-
- >
- ) : (
- threadIcons
- )}
-
- R:
{replyCount || '0'}
- {linkCount > 0 && (
+ const postContent = (
+
+ {hidden ? (
+
({t('hidden')})
+ ) : (
+ <>
+ {title && (
- {' '}
- / L: {linkCount}
+ {title}
+ {content ? ': ' : ''}
)}
-
-
-
-
-
{(showOPComment || isTextOnlyThread) && (hasThumbnail ? postContent : {postContent})}
-
+ {content &&
}
+ >
+ )}
- {hoveredCid === cid &&
- showPortal &&
- createPortal(
-
- {title ? (
+ );
+
+ const { imageSize, showOPComment } = useCatalogStyleStore();
+ const maxWidth = imageSize === 'Large' ? '250px' : '150px';
+ const maxHeight = imageSize === 'Large' ? '250px' : '150px';
+ const CSSProperties = {
+ '--maxWidth': maxWidth,
+ '--maxHeight': maxHeight,
+ } as React.CSSProperties;
+
+ const isTextOnlyThread = !hasThumbnail;
+
+ return (
+ <>
+
+
setHoveredCid(cid)} onMouseLeave={() => setHoveredCid(null)}>
+ {hidden ? (
+
+
+
+ ) : hasThumbnail ? (
<>
-
{title}
- {t('by')}
+ {shouldShowSnow() && hasThumbnail &&

}
+
+
(timeoutRef.current = setTimeout(() => setShowPortal(true), 250))}
+ onMouseLeave={() => {
+ setShowPortal(false);
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ timeoutRef.current = null;
+ }
+ }}
+ >
+ {threadIcons}
+ {spoiler ? (
+

+ ) : (
+
+ )}
+
+
>
) : (
- t('posted_by')
- )}{' '}
-
- {author?.displayName || _.capitalize(t('anonymous'))}
- {isCatalogPostAuthorMod && {` ## Board ${catalogPostAuthorRole}`}}
-
- {(isInAllView || isInSubscriptionsView) && subplebbitAddress && ` to p/${Plebbit.getShortAddress({ address: subplebbitAddress })}`}
-
{getFormattedTimeAgo(timestamp)}
- {replyCount > 0 && (
-
- {t('last_reply_by')}{' '}
-
- {lastReply?.author?.displayName || _.capitalize(t('anonymous'))}
- {isLastReplyAuthorMod && ` ## Board ${lastReplyAuthorRole}`}
-
- {getFormattedTimeAgo(lastReply?.timestamp)}
-
+ threadIcons
)}
-
,
- document.body,
- )}
- >
- );
-};
+
+ R:
{replyCount || '0'}
+ {linkCount > 0 && (
+
+ {' '}
+ / L: {linkCount}
+
+ )}
+
+
+
+
+
{(showOPComment || isTextOnlyThread) && (hasThumbnail ? postContent : {postContent})}
+
+
+ {hoveredCid === cid &&
+ showPortal &&
+ createPortal(
+
+ {title ? (
+ <>
+
{title}
+ {t('by')}
+ >
+ ) : (
+ t('posted_by')
+ )}{' '}
+
+ {author?.displayName || _.capitalize(t('anonymous'))}
+ {isCatalogPostAuthorMod && {` ## Board ${catalogPostAuthorRole}`}}
+
+ {(isInAllView || isInSubscriptionsView) && subplebbitAddress && ` to p/${Plebbit.getShortAddress({ address: subplebbitAddress })}`}
+
{getFormattedTimeAgo(timestamp)}
+ {replyCount > 0 && (
+
+ {t('last_reply_by')}{' '}
+
+ {lastReply?.author?.displayName || _.capitalize(t('anonymous'))}
+ {isLastReplyAuthorMod && ` ## Board ${lastReplyAuthorRole}`}
+
+ {getFormattedTimeAgo(lastReply?.timestamp)}
+
+ )}
+
,
+ document.body,
+ )}
+ >
+ );
+ },
+ (prevProps, nextProps) => prevProps.post?.cid === nextProps.post?.cid,
+);
interface CatalogRowProps {
index?: number;
diff --git a/src/components/post-desktop/post-desktop.tsx b/src/components/post-desktop/post-desktop.tsx
index 32616a32..91935a0c 100644
--- a/src/components/post-desktop/post-desktop.tsx
+++ b/src/components/post-desktop/post-desktop.tsx
@@ -391,7 +391,7 @@ const PostDesktop = ({ post, roles, showAllReplies, showReplies = true }: PostPr
const { hidden, unhide, hide } = useHide({ cid });
const isHidden = hidden && !isInPostPageView;
- const { replies, hasMore, loadMore } = useReplies({ comment: post });
+ const { replies, hasMore, loadMore } = useReplies({ comment: post, flat: true });
const visiblelinksCount = useCountLinksInReplies(post, 5);
const totalLinksCount = useCountLinksInReplies(post);
const replyCount = replies?.length;
diff --git a/src/components/post-form/post-form.tsx b/src/components/post-form/post-form.tsx
index 8e05bc32..c9b41960 100644
--- a/src/components/post-form/post-form.tsx
+++ b/src/components/post-form/post-form.tsx
@@ -14,6 +14,19 @@ import { useResolvedSubplebbitAddress } from '../../hooks/use-resolved-subplebbi
import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame';
import useIsSubplebbitOffline from '../../hooks/use-is-subplebbit-offline';
import usePublishPost from '../../hooks/use-publish-post';
+
+// Separate component for offline alert to isolate rerenders from updatingState
+// Only this component will rerender when updatingState changes, not the whole PostForm
+const OfflineAlert = ({ subplebbitAddress }: { subplebbitAddress: string | undefined }) => {
+ const subplebbit = useSubplebbitsStore((state) => (subplebbitAddress ? state.subplebbits[subplebbitAddress] : undefined));
+ const { isOffline, isOnlineStatusLoading, offlineTitle } = useIsSubplebbitOffline(subplebbit);
+
+ if (!isOffline && !isOnlineStatusLoading) {
+ return null;
+ }
+
+ return
{offlineTitle}
;
+};
import usePublishReply from '../../hooks/use-publish-reply';
import FileUploader from '../../plugins/file-uploader';
import styles from './post-form.module.css';
@@ -380,15 +393,11 @@ const PostForm = () => {
const accountComment = useAccountComment({ commentIndex: params?.accountCommentIndex as any });
const resolvedAddress = useResolvedSubplebbitAddress();
const subplebbitAddress = resolvedAddress || accountComment?.subplebbitAddress;
- const subplebbit = useSubplebbitsStore((state) => state.subplebbits[subplebbitAddress]);
- const { isOffline, isOnlineStatusLoading, offlineTitle } = useIsSubplebbitOffline(subplebbit);
return (
<>
- {!(isInAllView || isInSubscriptionsView || isInModView) && showForm && (isOffline || isOnlineStatusLoading) && (
-
{offlineTitle}
- )}
+ {!(isInAllView || isInSubscriptionsView || isInModView) && showForm &&
}
{isThreadClosed ? (
{t('thread_closed')}
@@ -408,9 +417,7 @@ const PostForm = () => {
)}
- {!(isInAllView || isInSubscriptionsView || isInModView) && showForm && (isOffline || isOnlineStatusLoading) && (
-
{offlineTitle}
- )}
+ {!(isInAllView || isInSubscriptionsView || isInModView) && showForm &&
}
{isThreadClosed ? (
{t('thread_closed')}
diff --git a/src/components/post-mobile/post-mobile.tsx b/src/components/post-mobile/post-mobile.tsx
index a925e17e..def5d098 100644
--- a/src/components/post-mobile/post-mobile.tsx
+++ b/src/components/post-mobile/post-mobile.tsx
@@ -231,7 +231,7 @@ const PostMediaContent = ({ post, link }: { post: any; link: string }) => {
const ReplyBacklinks = ({ post }: PostProps) => {
const { cid, parentCid } = post || {};
- const { replies } = useReplies({ comment: post });
+ const { replies } = useReplies({ comment: post, flat: true });
return (
cid &&
diff --git a/src/components/reply-modal/reply-modal.tsx b/src/components/reply-modal/reply-modal.tsx
index 34a05d91..5f7a790e 100644
--- a/src/components/reply-modal/reply-modal.tsx
+++ b/src/components/reply-modal/reply-modal.tsx
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import { useLocation, useParams } from 'react-router-dom';
import { Trans, useTranslation } from 'react-i18next';
import { setAccount, useAccount } from '@plebbit/plebbit-react-hooks';
-import useSubplebbitsStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits';
+import { useSubplebbitField } from '../../hooks/use-stable-subplebbit';
import { formatMarkdown } from '../../lib/utils/post-utils';
import { getFormattedTimeAgo } from '../../lib/utils/time-utils';
import { isValidURL } from '../../lib/utils/url-utils';
@@ -140,9 +140,9 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
const location = useLocation();
const isInAllView = isAllView(location.pathname);
const isInSubscriptionsView = isSubscriptionsView(location.pathname, useParams());
- const subplebbit = useSubplebbitsStore((state) => state.subplebbits[subplebbitAddress]);
- const { updatedAt } = subplebbit || {};
- const isBoardOffline = subplebbit?.updatedAt && subplebbit.updatedAt < Date.now() / 1000 - 60 * 60;
+ // Only subscribe to updatedAt to avoid rerenders from updatingState changes
+ const updatedAt = useSubplebbitField(subplebbitAddress, (subplebbit) => subplebbit?.updatedAt);
+ const isBoardOffline = updatedAt && updatedAt < Date.now() / 1000 - 60 * 60;
const offlineAlert = updatedAt
? isBoardOffline && (
diff --git a/src/components/subplebbit-stats/subplebbit-stats.tsx b/src/components/subplebbit-stats/subplebbit-stats.tsx
index 641e8046..5212d562 100644
--- a/src/components/subplebbit-stats/subplebbit-stats.tsx
+++ b/src/components/subplebbit-stats/subplebbit-stats.tsx
@@ -1,7 +1,7 @@
import { useParams } from 'react-router-dom';
import { Trans, useTranslation } from 'react-i18next';
import { useAccountComment, useSubplebbitStats } from '@plebbit/plebbit-react-hooks';
-import useSubplebbitsStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits';
+import { useSubplebbitField } from '../../hooks/use-stable-subplebbit';
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';
@@ -15,8 +15,9 @@ const SubplebbitStats = () => {
const resolvedAddress = useResolvedSubplebbitAddress();
const subplebbitAddress = resolvedAddress || accountComment?.subplebbitAddress;
- const subplebbit = useSubplebbitsStore((state) => state.subplebbits[subplebbitAddress]);
- const { address, createdAt } = subplebbit || {};
+ // Only subscribe to address and createdAt to avoid rerenders from updatingState changes
+ const address = useSubplebbitField(subplebbitAddress, (subplebbit) => subplebbit?.address);
+ const createdAt = useSubplebbitField(subplebbitAddress, (subplebbit) => subplebbit?.createdAt);
let stats = useSubplebbitStats({ subplebbitAddress: address });
const { hiddenStats, toggleVisibility } = useSubplebbitStatsVisibilityStore();
diff --git a/src/components/topbar/topbar.tsx b/src/components/topbar/topbar.tsx
index d5b4bb6d..a4d529de 100644
--- a/src/components/topbar/topbar.tsx
+++ b/src/components/topbar/topbar.tsx
@@ -2,7 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import Plebbit from '@plebbit/plebbit-js';
-import { useAccount, useAccountComment, useAccountSubplebbits } from '@plebbit/plebbit-react-hooks';
+import { useAccountComment } from '@plebbit/plebbit-react-hooks';
+import useAccountsStore from '@plebbit/plebbit-react-hooks/dist/stores/accounts';
import { isAllView, isCatalogView, isSubscriptionsView } from '../../lib/utils/view-utils';
import { useDefaultSubplebbits, MultisubSubplebbit } from '../../hooks/use-default-subplebbits';
import { useBoardPath, useResolvedSubplebbitAddress } from '../../hooks/use-resolved-subplebbit-address';
@@ -87,7 +88,6 @@ const findBoardAddressByCode = (code: string, defaultSubplebbits: MultisubSubple
const TopBarDesktop = () => {
const { t } = useTranslation();
- const account = useAccount();
const location = useLocation();
const params = useParams();
const isInCatalogView = isCatalogView(location.pathname, params);
@@ -102,9 +102,34 @@ const TopBarDesktop = () => {
// Memoize allBoardCodes since it's derived from a constant
const allBoardCodes = useMemo(() => getAllBoardCodes(), []);
- const subscriptions = account?.subscriptions || [];
- const { accountSubplebbits } = useAccountSubplebbits();
- const accountSubplebbitAddresses = Object.keys(accountSubplebbits);
+ // Use accounts store with selective subscriptions to avoid rerenders from updatingState
+ // Only subscribe to subscriptions array and account subplebbit addresses
+ const subscriptions = useAccountsStore(
+ (state) => {
+ const activeAccountId = state.activeAccountId;
+ const activeAccount = activeAccountId ? state.accounts[activeAccountId] : undefined;
+ return activeAccount?.subscriptions || [];
+ },
+ (prev, next) => {
+ // Shallow compare arrays - only rerender if subscriptions actually change
+ if (prev.length !== next.length) return false;
+ return prev.every((val, idx) => val === next[idx]);
+ },
+ );
+
+ const accountSubplebbitAddresses = useAccountsStore(
+ (state) => {
+ const activeAccountId = state.activeAccountId;
+ const activeAccount = activeAccountId ? state.accounts[activeAccountId] : undefined;
+ const accountSubplebbits = activeAccount?.subplebbits || {};
+ return Object.keys(accountSubplebbits);
+ },
+ (prev, next) => {
+ // Shallow compare arrays - only rerender if addresses actually change
+ if (prev.length !== next.length) return false;
+ return prev.every((val, idx) => val === next[idx]);
+ },
+ );
// Filter subscriptions to only show visible ones
const visibleSubscriptionAddresses = subscriptions.filter((address: string) => visibleSubscriptions.has(address));
@@ -243,8 +268,21 @@ const TopBarMobile = ({ subplebbitAddress }: { subplebbitAddress: string }) => {
const boardPath = useBoardPath(subplebbitAddress);
const selectValue = isInAllView ? 'all' : isInSubscriptionsView ? 'subs' : boardPath || subplebbitAddress;
- const { accountSubplebbits } = useAccountSubplebbits();
- const accountSubplebbitAddresses = Object.keys(accountSubplebbits);
+ // Use accounts store with selective subscriptions to avoid rerenders from updatingState
+ // Only subscribe to account subplebbit addresses (keys only)
+ const accountSubplebbitAddresses = useAccountsStore(
+ (state) => {
+ const activeAccountId = state.activeAccountId;
+ const activeAccount = activeAccountId ? state.accounts[activeAccountId] : undefined;
+ const accountSubplebbits = activeAccount?.subplebbits || {};
+ return Object.keys(accountSubplebbits);
+ },
+ (prev, next) => {
+ // Shallow compare arrays - only rerender if addresses actually change
+ if (prev.length !== next.length) return false;
+ return prev.every((val, idx) => val === next[idx]);
+ },
+ );
// Check if current subplebbit is a directory board
const currentIsDirectoryBoard = directoryBoards.some((board) => board.address === subplebbitAddress);
diff --git a/src/hooks/use-author-privileges.ts b/src/hooks/use-author-privileges.ts
index 3d79a33b..827e381e 100644
--- a/src/hooks/use-author-privileges.ts
+++ b/src/hooks/use-author-privileges.ts
@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { useAccount } from '@plebbit/plebbit-react-hooks';
-import useSubplebbitsStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits';
+import { useSubplebbitField } from './use-stable-subplebbit';
interface AuthorPrivilegesProps {
commentAuthorAddress: string;
@@ -11,8 +11,8 @@ interface AuthorPrivilegesProps {
const useAuthorPrivileges = ({ commentAuthorAddress, subplebbitAddress }: AuthorPrivilegesProps) => {
const account = useAccount();
const accountAuthorAddress = account?.author?.address;
- const subplebbit = useSubplebbitsStore((state) => state.subplebbits[subplebbitAddress]);
- const { roles } = subplebbit || {};
+ // Only subscribe to roles field to avoid rerenders from updatingState changes
+ const roles = useSubplebbitField(subplebbitAddress, (subplebbit) => subplebbit?.roles);
const { isCommentAuthorMod, isAccountMod, isAccountCommentAuthor, commentAuthorRole, accountAuthorRole } = useMemo(() => {
const commentAuthorRole = roles?.[commentAuthorAddress]?.role;
const isCommentAuthorMod = commentAuthorRole === 'admin' || commentAuthorRole === 'owner' || commentAuthorRole === 'moderator';
diff --git a/src/hooks/use-popular-posts.ts b/src/hooks/use-popular-posts.ts
index 6b1385a7..b41812c7 100644
--- a/src/hooks/use-popular-posts.ts
+++ b/src/hooks/use-popular-posts.ts
@@ -1,82 +1,69 @@
-import { useEffect, useState } from 'react';
+import { useMemo, useRef } from 'react';
import { Subplebbit } from '@plebbit/plebbit-react-hooks';
import { getCommentMediaInfo, getHasThumbnail } from '../lib/utils/media-utils';
+/**
+ * Extracts popular posts from subplebbits.
+ * Uses memoization to avoid recomputing when only updatingState changes.
+ */
const usePopularPosts = (subplebbits: Subplebbit[]) => {
- const [popularPosts, setPopularPosts] = useState
([]);
- const [isLoading, setIsLoading] = useState(true);
- const [error, setError] = useState(null);
+ // Track the previous CID list to detect actual content changes vs transient state changes
+ const prevCidsRef = useRef('');
- useEffect(() => {
- const fetchPopularPosts = () => {
- try {
- setIsLoading(true);
- setError(null);
- const uniqueLinks: Set = new Set();
- const allPosts: Comment[] = [];
+ const { popularPosts, error } = useMemo(() => {
+ try {
+ const uniqueLinks: Set = new Set();
+ const allPosts: Comment[] = [];
- const postsPerSub = [0, 8, 4, 3, 2, 2, 2, 2, 1][Math.min(subplebbits.length, 8)];
+ const postsPerSub = [0, 8, 4, 3, 2, 2, 2, 2, 1][Math.min(subplebbits.length, 8)];
- subplebbits.forEach((subplebbit: any) => {
- let subplebbitPosts: Comment[] = [];
+ subplebbits.forEach((subplebbit: any) => {
+ let subplebbitPosts: Comment[] = [];
- if (subplebbit?.posts?.pages?.hot?.comments) {
- for (const post of Object.values(subplebbit.posts.pages.hot.comments as Comment)) {
- const {
- deleted,
- link,
- linkHeight,
- linkWidth,
- locked,
- pinned,
- removed,
- replyCount,
- thumbnailUrl,
- // timestamp
- } = post;
+ if (subplebbit?.posts?.pages?.hot?.comments) {
+ for (const post of Object.values(subplebbit.posts.pages.hot.comments as Comment)) {
+ const { deleted, link, linkHeight, linkWidth, locked, pinned, removed, replyCount, thumbnailUrl } = post;
- try {
- const commentMediaInfo = getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
- const hasThumbnail = getHasThumbnail(commentMediaInfo, link);
+ try {
+ const commentMediaInfo = getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
+ const hasThumbnail = getHasThumbnail(commentMediaInfo, link);
- if (
- hasThumbnail &&
- replyCount > 1 &&
- !deleted &&
- !removed &&
- !locked &&
- !pinned &&
- // timestamp > Date.now() / 1000 - 60 * 60 * 24 * 30 &&
- !uniqueLinks.has(link)
- ) {
- subplebbitPosts.push(post);
- uniqueLinks.add(link);
- }
- } catch (err) {
- console.error('Error processing post:', err);
+ if (hasThumbnail && replyCount > 1 && !deleted && !removed && !locked && !pinned && !uniqueLinks.has(link)) {
+ subplebbitPosts.push(post);
+ uniqueLinks.add(link);
}
+ } catch (err) {
+ console.error('Error processing post:', err);
}
-
- subplebbitPosts.sort((a: any, b: any) => b.timestamp - a.timestamp);
- allPosts.push(...subplebbitPosts.slice(0, postsPerSub));
}
- });
- const sortedPosts = allPosts.sort((a: any, b: any) => b.timestamp - a.timestamp).slice(0, 8);
+ subplebbitPosts.sort((a: any, b: any) => b.timestamp - a.timestamp);
+ allPosts.push(...subplebbitPosts.slice(0, postsPerSub));
+ }
+ });
- setPopularPosts(sortedPosts);
- } catch (err) {
- console.error('Error in usePopularPosts:', err);
- setError('Failed to fetch popular posts');
- } finally {
- setIsLoading(false);
- }
- };
+ const sortedPosts = allPosts.sort((a: any, b: any) => b.timestamp - a.timestamp).slice(0, 8);
- fetchPopularPosts();
+ return { popularPosts: sortedPosts, error: null };
+ } catch (err) {
+ console.error('Error in usePopularPosts:', err);
+ return { popularPosts: [], error: 'Failed to fetch popular posts' };
+ }
}, [subplebbits]);
- return { popularPosts, isLoading, error };
+ // Create stable reference: only update if the CIDs actually change
+ // This prevents unnecessary rerenders when only updatingState changes
+ const currentCids = popularPosts.map((p: any) => p.cid).join(',');
+ const stablePostsRef = useRef(popularPosts);
+
+ if (currentCids !== prevCidsRef.current) {
+ prevCidsRef.current = currentCids;
+ stablePostsRef.current = popularPosts;
+ }
+
+ const isLoading = stablePostsRef.current.length === 0;
+
+ return { popularPosts: stablePostsRef.current, isLoading, error };
};
export default usePopularPosts;
diff --git a/src/hooks/use-stable-subplebbit.ts b/src/hooks/use-stable-subplebbit.ts
new file mode 100644
index 00000000..38397bd8
--- /dev/null
+++ b/src/hooks/use-stable-subplebbit.ts
@@ -0,0 +1,56 @@
+import { useMemo } from 'react';
+import useSubplebbitsStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits';
+
+/**
+ * Custom equality function that ignores transient state properties
+ * like updatingState, state, errors, etc. Only compares stable content fields.
+ */
+const isSubplebbitEqual = (prev: any, next: any): boolean => {
+ if (prev === next) return true;
+ if (!prev || !next) return prev === next;
+
+ // Compare only stable fields, ignore transient state
+ return (
+ prev.address === next.address &&
+ prev.title === next.title &&
+ prev.shortAddress === next.shortAddress &&
+ prev.roles === next.roles &&
+ prev.updatedAt === next.updatedAt &&
+ prev.createdAt === next.createdAt &&
+ prev.description === next.description
+ );
+};
+
+/**
+ * Hook to get a subplebbit with stable reference that ignores updatingState changes.
+ * Use this when you only need content fields and don't care about loading states.
+ *
+ * @param subplebbitAddress - The address of the subplebbit to retrieve
+ * @returns The subplebbit object, or undefined if not found
+ */
+export const useStableSubplebbit = (subplebbitAddress: string | undefined) => {
+ // Use selector with custom equality to ignore transient state
+ const subplebbit = useSubplebbitsStore((state) => (subplebbitAddress ? state.subplebbits[subplebbitAddress] : undefined), isSubplebbitEqual);
+
+ return subplebbit;
+};
+
+/**
+ * Hook to get only specific fields from a subplebbit, ignoring updatingState.
+ * This is more efficient when you only need a few fields.
+ *
+ * @param subplebbitAddress - The address of the subplebbit
+ * @param selector - Function to extract the needed fields
+ * @returns The selected fields
+ */
+export const useSubplebbitField = (subplebbitAddress: string | undefined, selector: (subplebbit: any) => T): T | undefined => {
+ const field = useSubplebbitsStore(
+ (state) => {
+ const subplebbit = subplebbitAddress ? state.subplebbits[subplebbitAddress] : undefined;
+ return subplebbit ? selector(subplebbit) : undefined;
+ },
+ (prev, next) => prev === next,
+ );
+
+ return field;
+};
diff --git a/src/views/board/board.tsx b/src/views/board/board.tsx
index dd924127..c669eedb 100644
--- a/src/views/board/board.tsx
+++ b/src/views/board/board.tsx
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Link, useLocation, useNavigationType, useParams } from 'react-router-dom';
-import { Comment, useAccount, useAccountComments, useAccountSubplebbits, useBlock, useFeed, useSubplebbit } from '@plebbit/plebbit-react-hooks';
+import { Comment, useAccount, useAccountComments, useAccountSubplebbits, useBlock, useFeed } from '@plebbit/plebbit-react-hooks';
+import { useStableSubplebbit, useSubplebbitField } from '../../hooks/use-stable-subplebbit';
import { Virtuoso, VirtuosoHandle, StateSnapshot } from 'react-virtuoso';
import { Trans, useTranslation } from 'react-i18next';
import styles from './board.module.css';
@@ -153,9 +154,13 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
}
}, [filteredComments, reset]);
- const subplebbit = useSubplebbit({ subplebbitAddress });
- const { error, shortAddress, state } = subplebbit || {};
- const title = isInAllView ? t('all') : isInSubscriptionsView ? t('subscriptions') : isInModView ? t('mod') : subplebbit?.title;
+ // Use stable subplebbit fields to avoid rerenders from updatingState
+ const subplebbitTitle = useSubplebbitField(subplebbitAddress, (sub) => sub?.title);
+ const shortAddress = useSubplebbitField(subplebbitAddress, (sub) => sub?.shortAddress);
+ // Only subscribe to state and error for footer display - these are needed
+ const stableSubplebbit = useStableSubplebbit(subplebbitAddress);
+ const { error, state } = stableSubplebbit || {};
+ const title = isInAllView ? t('all') : isInSubscriptionsView ? t('subscriptions') : isInModView ? t('mod') : subplebbitTitle;
const { blocked, unblock } = useBlock({ address: subplebbitAddress });
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 6ddb8178..59af0892 100644
--- a/src/views/home/popular-threads-box/popular-threads-box.tsx
+++ b/src/views/home/popular-threads-box/popular-threads-box.tsx
@@ -1,4 +1,4 @@
-import { useMemo } from 'react';
+import { memo, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Comment, Subplebbit } from '@plebbit/plebbit-react-hooks';
@@ -25,36 +25,41 @@ export const ContentPreview = ({ content, maxLength = 99 }: { content: string; m
return truncatedText;
};
-const PopularThreadCard = ({ post, multisub }: PopularThreadProps) => {
- const { cid, content, link, linkHeight, linkWidth, subplebbitAddress, thumbnailUrl, title } = post || {};
- const commentMediaInfo = getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
- const defaultSubplebbits = useDefaultSubplebbits();
+// Memoize to prevent rerenders when parent rerenders due to updatingState
+const PopularThreadCard = memo(
+ ({ post, multisub }: PopularThreadProps) => {
+ const { cid, content, link, linkHeight, linkWidth, subplebbitAddress, thumbnailUrl, title } = post || {};
+ const commentMediaInfo = getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
+ const defaultSubplebbits = useDefaultSubplebbits();
- // Find the matching MultisubSubplebbit entry and get its title
- const multisubEntry = multisub.find((ms) => ms?.address === subplebbitAddress);
- const boardTitle = multisubEntry?.title?.replace(/^\/[^/]+\/\s*-\s*/, '') || '';
- const boardPath = subplebbitAddress ? getBoardPath(subplebbitAddress, defaultSubplebbits) : '';
+ // Find the matching MultisubSubplebbit entry and get its title
+ const multisubEntry = multisub.find((ms) => ms?.address === subplebbitAddress);
+ const boardTitle = multisubEntry?.title?.replace(/^\/[^/]+\/\s*-\s*/, '') || '';
+ const boardPath = subplebbitAddress ? getBoardPath(subplebbitAddress, defaultSubplebbits) : '';
- return (
-
-
{boardTitle}
-
-
-
-
+ return (
+
+
{boardTitle}
+
+
+
+
+
+
+ {title && (
+ <>
+ {title.trim()}
+ {content && ': '}
+ >
+ )}
+ {content && }
+
-
- {title && (
- <>
- {title.trim()}
- {content && ': '}
- >
- )}
- {content && }
-
-
- );
-};
+ );
+ },
+ // Custom equality: only rerender if post.cid changes
+ (prevProps, nextProps) => prevProps.post?.cid === nextProps.post?.cid,
+);
const PopularThreadsBox = ({ multisub, subplebbits }: { multisub: MultisubSubplebbit[]; subplebbits: any }) => {
const { t } = useTranslation();
diff --git a/src/views/not-found/not-found.tsx b/src/views/not-found/not-found.tsx
index e242aee4..82c98fe3 100644
--- a/src/views/not-found/not-found.tsx
+++ b/src/views/not-found/not-found.tsx
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
-import useSubplebbitsStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits';
+import { useSubplebbitField } from '../../hooks/use-stable-subplebbit';
import { useDefaultSubplebbits } from '../../hooks/use-default-subplebbits';
import { getSubplebbitAddress } from '../../lib/utils/route-utils';
import { HomeLogo } from '../home';
@@ -20,8 +20,9 @@ const NotFound = () => {
const boardIdentifier = pathParts[0] && pathParts[0] !== 'not-found' && pathParts[0] !== 'faq' ? pathParts[0] : '';
const defaultSubplebbits = useDefaultSubplebbits();
const subplebbitAddress = boardIdentifier ? getSubplebbitAddress(boardIdentifier, defaultSubplebbits) : '';
- const subplebbit = useSubplebbitsStore((state) => state.subplebbits[subplebbitAddress]);
- const { address, shortAddress } = subplebbit || {};
+ // Only subscribe to address and shortAddress to avoid rerenders from updatingState changes
+ const address = useSubplebbitField(subplebbitAddress, (subplebbit) => subplebbit?.address);
+ const shortAddress = useSubplebbitField(subplebbitAddress, (subplebbit) => subplebbit?.shortAddress);
return (
diff --git a/src/views/post/post.tsx b/src/views/post/post.tsx
index 0e25cd99..bbe487a4 100644
--- a/src/views/post/post.tsx
+++ b/src/views/post/post.tsx
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Comment, Role, useComment, useEditedComment, useSubplebbit } from '@plebbit/plebbit-react-hooks';
import useSubplebbitsStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits';
+import { useSubplebbitField } from '../../hooks/use-stable-subplebbit';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { isAllView } from '../../lib/utils/view-utils';
import { useResolvedSubplebbitAddress } from '../../hooks/use-resolved-subplebbit-address';
@@ -27,7 +28,8 @@ export interface PostProps {
}
export const Post = ({ post, showAllReplies = false, showReplies = true }: PostProps) => {
- const subplebbit = useSubplebbitsStore((state) => state.subplebbits[post?.subplebbitAddress]);
+ // Only subscribe to roles field to avoid rerenders from updatingState changes
+ const roles = useSubplebbitField(post?.subplebbitAddress, (subplebbit) => subplebbit?.roles);
const isMobile = useIsMobile();
let comment = post;
@@ -42,9 +44,9 @@ export const Post = ({ post, showAllReplies = false, showReplies = true }: PostP
{isMobile ? (
-
+
) : (
-
+
)}