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