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 ? ( - + ) : ( - + )}