From fa32833b2a03b299196218e60c35911072f9ae80 Mon Sep 17 00:00:00 2001 From: plebeius Date: Fri, 2 Jan 2026 16:42:16 +0100 Subject: [PATCH] perf(components): prevent rerenders from updatingState Use Zustand selectors with custom equality functions to only subscribe to specific fields needed by components, avoiding unnecessary rerenders when transient state like updatingState changes. Extract offline indicators into separate components to isolate rerenders. Memoize card components in catalog and popular threads views. --- src/components/board-header/board-header.tsx | 60 +-- src/components/catalog-row/catalog-row.tsx | 354 +++++++++--------- src/components/post-desktop/post-desktop.tsx | 2 +- src/components/post-form/post-form.tsx | 23 +- src/components/post-mobile/post-mobile.tsx | 2 +- src/components/reply-modal/reply-modal.tsx | 8 +- .../subplebbit-stats/subplebbit-stats.tsx | 7 +- src/components/topbar/topbar.tsx | 52 ++- src/hooks/use-author-privileges.ts | 6 +- src/hooks/use-popular-posts.ts | 109 +++--- src/hooks/use-stable-subplebbit.ts | 56 +++ src/views/board/board.tsx | 13 +- .../popular-threads-box.tsx | 61 +-- src/views/not-found/not-found.tsx | 7 +- src/views/post/post.tsx | 8 +- 15 files changed, 446 insertions(+), 322 deletions(-) create mode 100644 src/hooks/use-stable-subplebbit.ts 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 ? ( - + ) : ( - + )}