From f3565f0dda132c67300c49eba49e868f2fc6bfbd Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Mon, 15 Jun 2026 18:10:58 +0700 Subject: [PATCH] perf(mod queue): stabilize moderation subscriptions --- src/hooks/__tests__/selector-hooks.test.tsx | 89 ++++++++++++++- src/hooks/use-account-community-addresses.ts | 5 +- .../use-moderated-community-addresses.ts | 105 +++++++++++++++--- src/views/mod-queue/mod-queue.tsx | 76 ++++++++----- 4 files changed, 229 insertions(+), 46 deletions(-) diff --git a/src/hooks/__tests__/selector-hooks.test.tsx b/src/hooks/__tests__/selector-hooks.test.tsx index df99679c..9abae147 100644 --- a/src/hooks/__tests__/selector-hooks.test.tsx +++ b/src/hooks/__tests__/selector-hooks.test.tsx @@ -9,6 +9,7 @@ import { useBoardFeedPageSize } from '../use-board-feed-page-size'; import { useBoardPseudonymityMode } from '../use-board-pseudonymity-mode'; import useCountLinksInReplies from '../use-count-links-in-replies'; import { useFilteredDirectoryAddresses } from '../use-filtered-directory-addresses'; +import { useModeratedCommunityAddressInputs, useModeratedCommunityAddressesForInputs } from '../use-moderated-community-addresses'; import useAllFeedFilterStore from '../../stores/use-all-feed-filter-store'; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -17,6 +18,7 @@ const act = (React as { act?: (cb: () => void | Promise) => void | Promise const testState = vi.hoisted(() => ({ account: undefined as unknown, accountCommunities: {} as Record, + liveCommunities: {} as Record, directories: [] as Array<{ address: string; nsfw?: boolean }>, directoryLookup: {} as Record, flattenedReplies: [] as unknown[], @@ -35,13 +37,14 @@ vi.mock('@bitsocial/bitsocial-react-hooks', () => ({ vi.mock('@bitsocial/bitsocial-react-hooks/dist/stores/accounts/index.js', () => ({ default: ( - selector: (state: { activeAccountId?: string; accounts: Record }) => unknown, + selector: (state: { activeAccountId?: string; accounts: Record & { communities?: typeof testState.accountCommunities }> }) => unknown, equalityFn?: (previous: unknown, next: unknown) => boolean, ) => { const nextValue = selector({ activeAccountId: 'active', accounts: { active: { + ...(testState.account as Record | undefined), communities: testState.accountCommunities, }, }, @@ -57,6 +60,27 @@ vi.mock('@bitsocial/bitsocial-react-hooks/dist/stores/accounts/index.js', () => }, })); +const communitiesStoreSelectorCache = vi.hoisted(() => ({ + hasValue: false, + value: undefined as unknown, +})); + +vi.mock('@bitsocial/bitsocial-react-hooks/dist/stores/communities', () => ({ + default: (selector: (state: { communities: Record }) => unknown, equalityFn?: (previous: unknown, next: unknown) => boolean) => { + const nextValue = selector({ + communities: testState.liveCommunities, + }); + + if (communitiesStoreSelectorCache.hasValue && equalityFn?.(communitiesStoreSelectorCache.value, nextValue)) { + return communitiesStoreSelectorCache.value; + } + + communitiesStoreSelectorCache.hasValue = true; + communitiesStoreSelectorCache.value = nextValue; + return nextValue; + }, +})); + vi.mock('@bitsocial/bitsocial-react-hooks/dist/lib/community-address.js', () => ({ getEquivalentCommunityAddressGroupKey: (address: string) => (address.endsWith('.eth') ? address.slice(0, -4) + '.bso' : address), pickPreferredEquivalentCommunityAddress: (addresses: string[]) => addresses.find((address) => address.endsWith('.bso')) || addresses[0], @@ -67,6 +91,7 @@ vi.mock('@bitsocial/bitsocial-react-hooks/dist/lib/utils', () => ({ })); vi.mock('../use-directories', () => ({ + normalizeBoardAddress: (address: string | undefined) => address?.toLowerCase() ?? '', useDirectories: () => testState.directories, useDirectoryByAddress: (address: string | undefined) => (address ? testState.directoryLookup[address] : undefined), })); @@ -110,12 +135,15 @@ describe('selector hooks', () => { vi.clearAllMocks(); testState.account = undefined; testState.accountCommunities = {}; + testState.liveCommunities = {}; testState.directories = []; testState.directoryLookup = {}; testState.flattenedReplies = []; testState.communitySnapshot = undefined; accountsStoreSelectorCache.hasValue = false; accountsStoreSelectorCache.value = undefined; + communitiesStoreSelectorCache.hasValue = false; + communitiesStoreSelectorCache.value = undefined; useAllFeedFilterStore.getState().setFilter('all'); container = document.createElement('div'); @@ -166,6 +194,65 @@ describe('selector hooks', () => { expect(addressesWithNewBoard).toEqual(['biz.eth', 'music.eth', 'tech.eth']); }); + it('keeps moderated board address identity stable when transient community state changes', () => { + testState.account = { + author: { address: '0xme' }, + subscriptions: ['sub.eth'], + }; + testState.accountCommunities = { + 'owned.eth': { address: 'owned.eth', state: 'updating' }, + }; + testState.directories = [{ address: 'dir.eth' }, { address: 'sub.eth' }]; + testState.liveCommunities = { + 'dir.eth': { + address: 'dir.eth', + state: 'fetching-ipns', + roles: { '0xme': { role: 'moderator' } }, + }, + 'sub.eth': { + address: 'sub.eth', + state: 'fetching-ipns', + roles: { '0xme': { role: 'member' } }, + }, + }; + + const useModeratedAddresses = () => { + const inputs = useModeratedCommunityAddressInputs(); + return useModeratedCommunityAddressesForInputs(inputs); + }; + + const initialAddresses = rerenderHookValue(useModeratedAddresses); + expect(initialAddresses).toEqual(['owned.eth', 'dir.eth']); + + testState.liveCommunities = { + 'dir.eth': { + address: 'dir.eth', + state: 'succeeded', + roles: { '0xme': { role: 'moderator' } }, + }, + 'sub.eth': { + address: 'sub.eth', + state: 'succeeded', + roles: { '0xme': { role: 'member' } }, + }, + }; + + expect(rerenderHookValue(useModeratedAddresses)).toBe(initialAddresses); + + testState.liveCommunities = { + ...testState.liveCommunities, + 'sub.eth': { + address: 'sub.eth', + state: 'succeeded', + roles: { '0xme': { role: 'admin' } }, + }, + }; + + const addressesWithNewRole = rerenderHookValue(useModeratedAddresses); + expect(addressesWithNewRole).not.toBe(initialAddresses); + expect(addressesWithNewRole).toEqual(['owned.eth', 'dir.eth', 'sub.eth']); + }); + it('computes moderator privileges and whether the current account authored the comment', () => { testState.account = { author: { address: '0xme' } }; testState.communitySnapshot = { diff --git a/src/hooks/use-account-community-addresses.ts b/src/hooks/use-account-community-addresses.ts index bb705694..8cd7f9db 100644 --- a/src/hooks/use-account-community-addresses.ts +++ b/src/hooks/use-account-community-addresses.ts @@ -12,10 +12,13 @@ type AccountsStoreState = { const EMPTY_ACCOUNT_COMMUNITY_ADDRESSES: string[] = []; -const areStringArraysEqual = (previous: string[], next: string[]) => { +export const areStringArraysEqual = (previous: readonly string[] | undefined, next: readonly string[] | undefined) => { if (previous === next) { return true; } + if (!previous || !next) { + return previous === next; + } if (previous.length !== next.length) { return false; } diff --git a/src/hooks/use-moderated-community-addresses.ts b/src/hooks/use-moderated-community-addresses.ts index 75a156c8..120b8242 100644 --- a/src/hooks/use-moderated-community-addresses.ts +++ b/src/hooks/use-moderated-community-addresses.ts @@ -1,9 +1,58 @@ import { useMemo } from 'react'; -import { useAccount, useCommunities } from '@bitsocial/bitsocial-react-hooks'; +import type { Community } from '@bitsocial/bitsocial-react-hooks'; +import useAccountsStore from '@bitsocial/bitsocial-react-hooks/dist/stores/accounts/index.js'; +import useCommunitiesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities'; import { useDirectories } from './use-directories'; -import { useCommunityIdentifiers } from './use-community-identifiers'; -import { useAccountCommunityAddresses } from './use-account-community-addresses'; +import { areStringArraysEqual, useAccountCommunityAddresses } from './use-account-community-addresses'; import { getModeratedCommunityAddresses } from '../lib/utils/mod-queue-utils'; +import { normalizeBoardAddress } from '../lib/utils/directory-list-lookup-utils'; + +type AccountModerationSnapshot = { + accountAddress: string | undefined; + subscriptions: readonly string[]; +}; + +type AccountWithModerationFields = { + author?: { + address?: string; + }; + subscriptions?: string[]; +}; + +type AccountsStoreState = { + activeAccountId?: string; + accounts: Record; +}; + +type CommunitiesStoreState = { + communities: Record; +}; + +export type ModeratedCommunityAddressInputs = { + accountAddress: string | undefined; + accountCommunityAddresses: string[]; + candidateCommunityAddresses: string[]; +}; + +const EMPTY_SUBSCRIPTIONS: readonly string[] = []; + +const areAccountModerationSnapshotsEqual = (previous: AccountModerationSnapshot | undefined, next: AccountModerationSnapshot | undefined) => { + if (previous === next) { + return true; + } + if (!previous || !next) { + return previous === next; + } + return previous.accountAddress === next.accountAddress && areStringArraysEqual(previous.subscriptions, next.subscriptions); +}; + +const getAccountModerationSnapshot = (state: AccountsStoreState): AccountModerationSnapshot => { + const account = state.activeAccountId ? state.accounts[state.activeAccountId] : undefined; + return { + accountAddress: account?.author?.address, + subscriptions: account?.subscriptions ?? EMPTY_SUBSCRIPTIONS, + }; +}; const addUniqueAddress = (addresses: string[], address: string | undefined) => { if (address && !addresses.includes(address)) { @@ -11,9 +60,21 @@ const addUniqueAddress = (addresses: string[], address: string | undefined) => { } }; -export const useModeratedCommunityAddresses = (): string[] => { - const account = useAccount(); - const accountAddress = account?.author?.address; +const getCommunityByAddress = (communities: Record, communityAddress: string): Community | undefined => { + const exactMatch = communities[communityAddress]; + if (exactMatch) { + return exactMatch; + } + + const normalizedAddress = normalizeBoardAddress(communityAddress); + return Object.entries(communities).find(([key, community]) => { + const candidateAddress = typeof community?.address === 'string' ? community.address : key; + return normalizeBoardAddress(candidateAddress) === normalizedAddress; + })?.[1]; +}; + +export const useModeratedCommunityAddressInputs = (): ModeratedCommunityAddressInputs => { + const { accountAddress, subscriptions } = useAccountsStore(getAccountModerationSnapshot, areAccountModerationSnapshotsEqual); const accountCommunityAddresses = useAccountCommunityAddresses(); const directories = useDirectories(); @@ -25,23 +86,39 @@ export const useModeratedCommunityAddresses = (): string[] => { for (const directory of directories) { addUniqueAddress(addresses, directory.address); } - for (const address of account?.subscriptions ?? []) { + for (const address of subscriptions) { addUniqueAddress(addresses, address); } return addresses; - }, [account?.subscriptions, accountCommunityAddresses, directories]); - - const candidateCommunities = useCommunityIdentifiers(candidateCommunityAddresses); - const { communities } = useCommunities(candidateCommunities.length > 0 ? { communities: candidateCommunities } : undefined); + }, [accountCommunityAddresses, directories, subscriptions]); return useMemo( - () => + () => ({ + accountAddress, + accountCommunityAddresses, + candidateCommunityAddresses, + }), + [accountAddress, accountCommunityAddresses, candidateCommunityAddresses], + ); +}; + +export const useModeratedCommunityAddressesForInputs = ({ + accountAddress, + accountCommunityAddresses, + candidateCommunityAddresses, +}: ModeratedCommunityAddressInputs): string[] => + useCommunitiesStore( + (state: CommunitiesStoreState) => getModeratedCommunityAddresses({ accountAddress, accountCommunityAddresses, candidateCommunityAddresses, - communities, + communities: candidateCommunityAddresses.map((candidateAddress) => getCommunityByAddress(state.communities, candidateAddress)), }), - [accountAddress, accountCommunityAddresses, candidateCommunityAddresses, communities], + areStringArraysEqual, ); + +export const useModeratedCommunityAddresses = (): string[] => { + const inputs = useModeratedCommunityAddressInputs(); + return useModeratedCommunityAddressesForInputs(inputs); }; diff --git a/src/views/mod-queue/mod-queue.tsx b/src/views/mod-queue/mod-queue.tsx index 5f6d6291..f0497aef 100644 --- a/src/views/mod-queue/mod-queue.tsx +++ b/src/views/mod-queue/mod-queue.tsx @@ -2,7 +2,7 @@ import React, { useMemo, useState, useEffect, useCallback, memo } from 'react'; import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { useParams, Link } from 'react-router-dom'; -import { useFeed, Comment, usePublishCommentModeration, useEditedComment, useCommunity, useAccount } from '@bitsocial/bitsocial-react-hooks'; +import { useFeed, Comment, usePublishCommentModeration, useEditedComment, useCommunity, useCommunities } from '@bitsocial/bitsocial-react-hooks'; import useAccountsStore from '@bitsocial/bitsocial-react-hooks/dist/stores/accounts/index.js'; import { useFloating, offset, shift, size, flip, autoUpdate } from '@floating-ui/react'; import { Virtuoso, type Components } from 'react-virtuoso'; @@ -49,7 +49,7 @@ import capitalize from 'lodash/capitalize'; import lowerCase from 'lodash/lowerCase'; import { PageFooterDesktop, PageFooterMobile, StyleOnlyFooterFirstRow } from '../../components/footer/footer'; import footerStyles from '../../components/footer/footer.module.css'; -import { useModeratedCommunityAddresses } from '../../hooks/use-moderated-community-addresses'; +import { useModeratedCommunityAddressInputs, useModeratedCommunityAddressesForInputs } from '../../hooks/use-moderated-community-addresses'; /** Path for display: directory code, or full address if has TLD, or shortened for long IPNS keys (no dot) */ const getBoardDisplayPath = (address: string, path: string): string => { @@ -124,6 +124,12 @@ const EMPTY_COMMENTS: Comment[] = []; const MOD_QUEUE_VIRTUOSO_INCREASE_VIEWPORT_BY = { bottom: 600, top: 600 }; const NOOP_LOAD_MORE = () => undefined; +const ModQueueCommunityMetadataLoader = memo(({ candidateCommunityAddresses }: { candidateCommunityAddresses: string[] }) => { + const candidateCommunities = useCommunityIdentifiers(candidateCommunityAddresses); + useCommunities(candidateCommunities.length > 0 ? { communities: candidateCommunities } : undefined); + return null; +}); + interface ModQueueFooterProps { hasMore: boolean; loadingStateString: string; @@ -1119,9 +1125,9 @@ const ModQueueButtonContent = ({ feed, alertThresholdSeconds, boardIdentifier, i export const ModQueueButton = ({ boardIdentifier, isMobile }: ModQueueButtonProps) => { const getAlertThresholdSeconds = useModQueueStore((state) => state.getAlertThresholdSeconds); - const account = useAccount(); - const accountAddress = account?.author?.address; - const rawAccountCommunityAddresses = useModeratedCommunityAddresses(); + const moderatedCommunityAddressInputs = useModeratedCommunityAddressInputs(); + const accountAddress = moderatedCommunityAddressInputs.accountAddress; + const rawAccountCommunityAddresses = useModeratedCommunityAddressesForInputs(moderatedCommunityAddressInputs); const accountCommunityAddressesKey = getAddressListKey(rawAccountCommunityAddresses); const accountCommunityAddresses = useMemo(() => getAddressListFromKey(accountCommunityAddressesKey), [accountCommunityAddressesKey]); @@ -1175,15 +1181,21 @@ export const ModQueueButton = ({ boardIdentifier, isMobile }: ModQueueButtonProp [feedCommunities], ); const { feed } = useFeed(feedOptions); + const metadataLoader = ; if (!shouldFetch || communityAddresses.length === 0) { - return null; + return metadataLoader; } const alertThresholdSeconds = getAlertThresholdSeconds(); // Remount when switching boards so memoized counts reset cleanly. const contentKey = communityAddresses.join(','); - return ; + return ( + <> + {metadataLoader} + + + ); }; const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProps) => { @@ -1195,7 +1207,8 @@ const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProp const rememberCommentsInQueue = useModQueueStore((state) => state.rememberCommentsInQueue); const isMobile = useIsMobile(); - const rawAccountCommunityAddresses = useModeratedCommunityAddresses(); + const moderatedCommunityAddressInputs = useModeratedCommunityAddressInputs(); + const rawAccountCommunityAddresses = useModeratedCommunityAddressesForInputs(moderatedCommunityAddressInputs); const accountCommunityAddressesKey = getAddressListKey(rawAccountCommunityAddresses); const accountCommunityAddresses = useMemo(() => getAddressListFromKey(accountCommunityAddressesKey), [accountCommunityAddressesKey]); @@ -1329,28 +1342,31 @@ const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProp const visibleVirtuosoFooterContext = isQueueEmpty ? null : virtuosoFooterContext; return ( - + <> + + + ); };