From 33bb1d1356fbf09693acb5003bcc6e7039a51b90 Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Sun, 14 Jun 2026 20:38:25 +0700 Subject: [PATCH] perf(mod queue): stabilize empty loading state Keep the mod queue empty state visible while background P2P loading continues, reduce footer-driven rerenders, and preserve footer errors when retained queue history rows are rendered. --- .../mod-queue/__tests__/mod-queue.test.tsx | 91 ++- src/views/mod-queue/mod-queue.tsx | 521 +++++++++++------- 2 files changed, 401 insertions(+), 211 deletions(-) diff --git a/src/views/mod-queue/__tests__/mod-queue.test.tsx b/src/views/mod-queue/__tests__/mod-queue.test.tsx index d9a896db..26d0d335 100644 --- a/src/views/mod-queue/__tests__/mod-queue.test.tsx +++ b/src/views/mod-queue/__tests__/mod-queue.test.tsx @@ -9,6 +9,7 @@ import ModQueueView from '../mod-queue'; const act = (React as { act?: (callback: () => void | Promise) => void | Promise }).act as (callback: () => void | Promise) => void | Promise; type TestComment = { + approved?: boolean; cid: string; content?: string; communityAddress?: string; @@ -21,6 +22,7 @@ const testState = vi.hoisted(() => ({ account: { author: { address: '0x123' }, id: 'account' }, accountCommunityAddresses: ['music-posting.eth'], addChallengeMock: vi.fn(), + communityError: null as Error | null, directories: [{ address: 'music-posting.eth', directoryCode: 'mu', title: '/mu/ - Music' }], dismissedCommentCids: [] as string[], feed: [] as TestComment[], @@ -60,6 +62,7 @@ vi.mock('react-i18next', () => ({ vi.mock('@bitsocial/bitsocial-react-hooks', () => ({ useAccount: () => testState.account, useCommunity: () => ({ + error: testState.communityError, roles: { '0x123': { role: 'moderator' }, }, @@ -115,10 +118,12 @@ vi.mock('@floating-ui/react', () => ({ vi.mock('react-virtuoso', () => ({ Virtuoso: ({ components, + context, data = [], itemContent, }: { - components?: { Footer?: React.ComponentType }; + components?: { Footer?: React.ComponentType<{ context?: unknown }> }; + context?: unknown; data?: TestComment[]; itemContent: (index: number, item: TestComment) => React.ReactNode; }) => @@ -126,7 +131,7 @@ vi.mock('react-virtuoso', () => ({ 'div', { 'data-testid': 'virtuoso' }, data.map((item, index) => createElement(React.Fragment, { key: item.cid }, itemContent(index, item))), - components?.Footer ? createElement(components.Footer) : null, + components?.Footer ? createElement(components.Footer, { context }) : null, ), })); @@ -172,10 +177,6 @@ vi.mock('../../../hooks/use-is-mobile', () => ({ default: () => testState.isMobile, })); -vi.mock('../../../hooks/use-state-string', () => ({ - useFeedStateString: () => 'loading_mod_queue', -})); - vi.mock('../../../components/error-display/error-display', () => ({ default: ({ error }: { error?: Error }) => createElement('div', { 'data-testid': 'error-display' }, error?.message || 'error'), })); @@ -258,6 +259,7 @@ describe('ModQueueView', () => { beforeEach(() => { vi.clearAllMocks(); testState.accountCommunityAddresses = ['music-posting.eth']; + testState.communityError = null; testState.directories = [{ address: 'music-posting.eth', directoryCode: 'mu', title: '/mu/ - Music' }]; testState.dismissedCommentCids = []; testState.feed = []; @@ -278,14 +280,83 @@ describe('ModQueueView', () => { container.remove(); }); - it('keeps the compact table hidden while an empty mod queue is still loading', async () => { + it('keeps the compact table visible with the empty state while an empty mod queue continues loading', async () => { testState.hasMore = true; await renderModQueue(); - expect(container.querySelector('[data-testid="loading-ellipsis"]')?.textContent).toBe('loading_mod_queue'); - expect(container.textContent).not.toContain('No.'); - expect(container.textContent).not.toContain('queue_is_empty'); + const text = container.textContent ?? ''; + expect(text).toContain('No.'); + expect(text).toContain('excerpt'); + expect(text).toContain('queue_is_empty'); + expect(text.indexOf('No.')).toBeLessThan(text.indexOf('queue_is_empty')); + expect(container.querySelector('[data-testid="loading-ellipsis"]')).toBeNull(); + }); + + it('does not render a loading footer for an empty all-boards mod queue', async () => { + testState.accountCommunityAddresses = ['music-posting.eth', 'tech-posting.eth']; + testState.directories = [ + { address: 'music-posting.eth', directoryCode: 'mu', title: '/mu/ - Music' }, + { address: 'tech-posting.eth', directoryCode: 'g', title: '/g/ - Technology' }, + ]; + testState.hasMore = true; + + await renderModQueue(); + + expect(container.textContent).toContain('queue_is_empty'); + expect(container.querySelector('[data-testid="loading-ellipsis"]')).toBeNull(); + }); + + it('keeps the empty queue state quiet when background community metadata fails', async () => { + testState.communityError = new Error('community unavailable'); + testState.hasMore = true; + + await renderModQueue(); + + expect(container.textContent).toContain('queue_is_empty'); + expect(container.querySelector('[data-testid="error-display"]')).toBeNull(); + expect(container.querySelector('[data-testid="loading-ellipsis"]')).toBeNull(); + }); + + it('shows a generic continuing load state after a queue item appears', async () => { + testState.hasMore = true; + testState.feed = [ + { + cid: 'pending-reply', + communityAddress: 'music-posting.eth', + content: 'pending reply body', + pendingApproval: true, + timestamp: 90_000, + }, + ]; + + await renderModQueue(); + + const loadingTexts = Array.from(container.querySelectorAll('[data-testid="loading-ellipsis"]')).map((element) => element.textContent); + expect(container.textContent).toContain('pending reply body'); + expect(loadingTexts).toContain('looking_for_more_posts'); + }); + + it('keeps the footer error visible when local queue history is shown while the live feed is empty', async () => { + testState.communityError = new Error('community unavailable'); + testState.hasMore = true; + testState.queuedCommentHistory = [ + { + approved: true, + cid: 'approved-history', + communityAddress: 'music-posting.eth', + content: 'recently approved body', + pendingApproval: false, + timestamp: 90_000, + }, + ]; + + await renderModQueue(); + + const loadingTexts = Array.from(container.querySelectorAll('[data-testid="loading-ellipsis"]')).map((element) => element.textContent); + expect(container.textContent).toContain('recently approved body'); + expect(container.querySelector('[data-testid="error-display"]')?.textContent).toBe('community unavailable'); + expect(loadingTexts).toContain('looking_for_more_posts'); }); it('keeps the compact table visible and renders the empty state under its header after loading', async () => { diff --git a/src/views/mod-queue/mod-queue.tsx b/src/views/mod-queue/mod-queue.tsx index 0784e58f..5f6d6291 100644 --- a/src/views/mod-queue/mod-queue.tsx +++ b/src/views/mod-queue/mod-queue.tsx @@ -5,13 +5,12 @@ import { useParams, Link } from 'react-router-dom'; import { useFeed, Comment, usePublishCommentModeration, useEditedComment, useCommunity, useAccount } 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 } from 'react-virtuoso'; +import { Virtuoso, type Components } from 'react-virtuoso'; import styles from './mod-queue.module.css'; import postStyles from '../post/post.module.css'; import useModQueueStore from '../../stores/use-mod-queue-store'; import LoadingEllipsis from '../../components/loading-ellipsis/loading-ellipsis'; import ErrorDisplay from '../../components/error-display/error-display'; -import { useFeedStateString } from '../../hooks/use-state-string'; import { getCommunityAddress, getBoardPath, areSameBoardAddress } from '../../lib/utils/route-utils'; import { useDirectories, DirectoryCommunity } from '../../hooks/use-directories'; import getShortAddress from '../../lib/get-short-address'; @@ -119,25 +118,85 @@ interface ModQueueViewProps { boardIdentifier?: string; // If provided, shows queue for single board } +const getAddressListKey = (addresses: string[]) => addresses.join('\0'); +const getAddressListFromKey = (key: string) => (key ? key.split('\0') : []); +const EMPTY_COMMENTS: Comment[] = []; +const MOD_QUEUE_VIRTUOSO_INCREASE_VIEWPORT_BY = { bottom: 600, top: 600 }; +const NOOP_LOAD_MORE = () => undefined; + interface ModQueueFooterProps { hasMore: boolean; - communityAddresses: string[]; + loadingStateString: string; } // Defined outside ModQueueView to preserve component identity across renders (Virtuoso optimization) -// The useFeedStateString hook is called here instead of in ModQueueView to isolate re-renders -// caused by backend IPFS state changes to just this footer component -const ModQueueFooter = ({ hasMore, communityAddresses }: ModQueueFooterProps) => { - const { t } = useTranslation(); - const loadingStateString = useFeedStateString(communityAddresses) || t('loading'); - +const ModQueueFooter = memo(({ hasMore, loadingStateString }: ModQueueFooterProps) => { return hasMore ? (
) : null; +}); +ModQueueFooter.displayName = 'ModQueueFooter'; + +const ModQueueContinuingFooter = memo(({ hasMore }: { hasMore: boolean }) => { + const { t } = useTranslation(); + + return ; +}); +ModQueueContinuingFooter.displayName = 'ModQueueContinuingFooter'; + +interface ModQueueVirtuosoFooterContext { + error: Error | null; + hasMore: boolean; +} + +const ModQueueVirtuosoFooter = memo(({ context }: { context?: ModQueueVirtuosoFooterContext }) => { + if (!context) { + return null; + } + + return ( + <> + {context.error && ( +
+ +
+ )} + + + ); +}); +ModQueueVirtuosoFooter.displayName = 'ModQueueVirtuosoFooter'; + +const MOD_QUEUE_VIRTUOSO_COMPONENTS: Components = { + Footer: ModQueueVirtuosoFooter, }; +const ModQueuePageFooter = memo(() => { + const { t } = useTranslation(); + const reset = useFeedResetStore((state) => state.reset); + + return ( + <> + } /> + +
+
+ + +
+
+
+ + ); +}); +ModQueuePageFooter.displayName = 'ModQueuePageFooter'; + interface ModQueueRowProps { comment: Comment; isOdd?: boolean; @@ -654,7 +713,7 @@ const ModQueueCard = memo(({ comment, showBoard = false, boardPath, boardDisplay }); ModQueueCard.displayName = 'ModQueueCard'; -const ModQueueFeedPost = ({ comment }: { comment: Comment }) => { +const ModQueueFeedPost = memo(({ comment }: { comment: Comment }) => { const { editedComment } = useEditedComment({ comment }); const displayComment = editedComment || comment; const { status, error, errorMessage, isPublishing, handleApprove, handleReject, handleRemove } = useModQueueActions(comment); @@ -673,7 +732,8 @@ const ModQueueFeedPost = ({ comment }: { comment: Comment }) => { onRemoveFromModQueue={handleRemove} /> ); -}; +}); +ModQueueFeedPost.displayName = 'ModQueueFeedPost'; interface ModQueueBoardSummaryProps { feed: Comment[]; @@ -704,7 +764,7 @@ const ModQueueBoardCount = ({ normal, urgent }: { normal: number; urgent: number ); }; -const ModQueueBoardSummary = ({ feed, directories, accountCommunityAddresses, selectedBoardFilter, setSelectedBoardFilter }: ModQueueBoardSummaryProps) => { +const ModQueueBoardSummary = memo(({ feed, directories, accountCommunityAddresses, selectedBoardFilter, setSelectedBoardFilter }: ModQueueBoardSummaryProps) => { const { t } = useTranslation(); const getAlertThresholdSeconds = useModQueueStore((state) => state.getAlertThresholdSeconds); const currentTime = useCurrentTime(); @@ -795,7 +855,206 @@ const ModQueueBoardSummary = ({ feed, directories, accountCommunityAddresses, se })} ); -}; +}); +ModQueueBoardSummary.displayName = 'ModQueueBoardSummary'; + +interface ModQueueContentProps { + accountCommunityAddresses: string[]; + addressToPathMap: Map; + boardSummaryFeed: Comment[]; + compactCardItemContent: (index: number, comment: Comment) => React.ReactNode; + compactRowItemContent: (index: number, comment: Comment) => React.ReactNode; + communityError: Error | null | undefined; + directories: DirectoryCommunity[]; + feedLength: number; + feedPostItemContent: (index: number, comment: Comment) => React.ReactNode; + filteredFeed: Comment[]; + hasMore: boolean; + isMobile: boolean; + isQueueEmpty: boolean; + loadMore: () => void; + resolvedAddress: string | undefined; + selectedBoardFilter: string | null; + setSelectedBoardFilter: React.Dispatch>; + showBoardColumn: boolean; + viewMode: 'compact' | 'feed'; + virtuosoFooterContext: ModQueueVirtuosoFooterContext | null; +} + +const ModQueueContent = memo( + ({ + accountCommunityAddresses, + addressToPathMap, + boardSummaryFeed, + compactCardItemContent, + compactRowItemContent, + communityError, + directories, + feedLength, + feedPostItemContent, + filteredFeed, + hasMore, + isMobile, + isQueueEmpty, + loadMore, + resolvedAddress, + selectedBoardFilter, + setSelectedBoardFilter, + showBoardColumn, + viewMode, + virtuosoFooterContext, + }: ModQueueContentProps) => { + const { t } = useTranslation(); + + return ( + <> +
+ {!resolvedAddress && ( +
+
+ +
+
+ )} + + {viewMode === 'compact' && !isMobile && ( + <> +
+
No.
+ {!resolvedAddress &&
{t('board')}
} +
{t('excerpt')}
+
{t('submitted')}
+
{t('type')}
+
{t('image')}
+
{t('actions')}
+
+ + {isQueueEmpty ? ( +
{t('queue_is_empty')}
+ ) : hasMore ? ( + + ) : ( + <> + {filteredFeed.map((comment, index) => { + const commentCommunityAddress = getCommentCommunityAddress(comment); + const path = + addressToPathMap.get(commentCommunityAddress || '') ?? (commentCommunityAddress ? getBoardPath(commentCommunityAddress, directories) : undefined); + return ( + + ); + })} + {communityError?.message && feedLength === 0 && ( +
+ +
+ )} + + + )} + + )} + + {viewMode === 'compact' && isMobile && ( + <> + {isQueueEmpty ? ( +
{t('queue_is_empty')}
+ ) : hasMore ? ( + + ) : ( + <> + {filteredFeed.map((comment) => { + const commentCommunityAddress = getCommentCommunityAddress(comment); + const path = + addressToPathMap.get(commentCommunityAddress || '') ?? (commentCommunityAddress ? getBoardPath(commentCommunityAddress, directories) : undefined); + return ( + + ); + })} + {communityError?.message && feedLength === 0 && ( +
+ +
+ )} + + + )} + + )} + + {viewMode === 'feed' && ( + <> + {isQueueEmpty ? ( +
{t('queue_is_empty')}
+ ) : hasMore ? ( + + ) : ( + <> + {filteredFeed.map((comment) => ( + + ))} + {communityError?.message && feedLength === 0 && ( +
+ +
+ )} + + + )} + + )} +
+ + + ); + }, +); +ModQueueContent.displayName = 'ModQueueContent'; interface ModQueueButtonProps { boardIdentifier?: string; @@ -862,7 +1121,9 @@ export const ModQueueButton = ({ boardIdentifier, isMobile }: ModQueueButtonProp const account = useAccount(); const accountAddress = account?.author?.address; - const accountCommunityAddresses = useModeratedCommunityAddresses(); + const rawAccountCommunityAddresses = useModeratedCommunityAddresses(); + const accountCommunityAddressesKey = getAddressListKey(rawAccountCommunityAddresses); + const accountCommunityAddresses = useMemo(() => getAddressListFromKey(accountCommunityAddressesKey), [accountCommunityAddressesKey]); const directories = useDirectories(); @@ -926,7 +1187,6 @@ export const ModQueueButton = ({ boardIdentifier, isMobile }: ModQueueButtonProp }; const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProps) => { - const { t } = useTranslation(); const params = useParams(); const [selectedBoardFilter, setSelectedBoardFilter] = useState(null); const viewMode = useModQueueStore((state) => state.viewMode); @@ -935,7 +1195,9 @@ const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProp const rememberCommentsInQueue = useModQueueStore((state) => state.rememberCommentsInQueue); const isMobile = useIsMobile(); - const accountCommunityAddresses = useModeratedCommunityAddresses(); + const rawAccountCommunityAddresses = useModeratedCommunityAddresses(); + const accountCommunityAddressesKey = getAddressListKey(rawAccountCommunityAddresses); + const accountCommunityAddresses = useMemo(() => getAddressListFromKey(accountCommunityAddressesKey), [accountCommunityAddressesKey]); const directories = useDirectories(); @@ -948,10 +1210,11 @@ const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProp return undefined; }, [boardIdentifier, directories]); - const communityAddresses = useMemo(() => { - if (resolvedAddress) return [resolvedAddress]; - return accountCommunityAddresses; - }, [resolvedAddress, accountCommunityAddresses]); + const communityAddressesKey = resolvedAddress ?? accountCommunityAddressesKey; + const communityAddresses = useMemo( + () => (resolvedAddress ? [resolvedAddress] : getAddressListFromKey(communityAddressesKey)), + [resolvedAddress, communityAddressesKey], + ); const communities = useCommunityIdentifiers(communityAddresses); const communityAddress = communityAddresses[0]; @@ -997,6 +1260,7 @@ const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProp () => filterVisibleModQueueFeed(feedWithHistory, selectedBoardFilter, dismissedCommentCidSet, selectedBoardFilterAddresses), [feedWithHistory, selectedBoardFilter, dismissedCommentCidSet, selectedBoardFilterAddresses], ); + const hasVisibleComments = filteredFeed.length > 0; const addressToPathMap = useMemo(() => { const map = new Map(); @@ -1040,198 +1304,53 @@ const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProp }, [addressToPathMap, showBoardColumn, directories], ); + const feedPostItemContent = useCallback((_index: number, comment: Comment) => , []); const setResetFunction = useFeedResetStore((state) => state.setResetFunction); useEffect(() => { setResetFunction(reset); }, [reset, setResetFunction]); - // Memoize footer components object to preserve identity across renders (Virtuoso optimization) - // Note: useFeedStateString is called inside ModQueueFooter to isolate re-renders from backend state changes - const footerComponents = useMemo( + const footerError = feed.length === 0 && communityError?.message ? communityError : null; + const virtuosoFooterContext = useMemo( () => ({ - Footer: () => ( - <> - {communityError?.message && feed.length === 0 && ( -
- -
- )} - - - ), + error: footerError, + hasMore, }), - [hasMore, communityAddresses, communityError, feed.length], + [footerError, hasMore], ); - - const pageFooter = ( - <> - } /> - -
-
- - -
-
-
- - ); - const isInitialFeedLoading = filteredFeed.length === 0 && hasMore; - const isQueueEmpty = filteredFeed.length === 0 && !hasMore; + const isQueueEmpty = !hasVisibleComments; + const boardSummaryFeed = feed.length > 0 ? feed : EMPTY_COMMENTS; + const visibleFilteredFeed = isQueueEmpty ? EMPTY_COMMENTS : filteredFeed; + const visibleHasMore = isQueueEmpty ? false : hasMore; + const visibleLoadMore = isQueueEmpty ? NOOP_LOAD_MORE : loadMore; + const visibleCommunityError = isQueueEmpty ? null : communityError; + const visibleFeedLength = isQueueEmpty ? 0 : feed.length; + const visibleVirtuosoFooterContext = isQueueEmpty ? null : virtuosoFooterContext; return ( - <> -
- {!resolvedAddress && ( -
-
- -
-
- )} - - {isInitialFeedLoading ? ( - - ) : ( - <> - {viewMode === 'compact' && !isMobile && ( - <> -
-
No.
- {!resolvedAddress &&
{t('board')}
} -
{t('excerpt')}
-
{t('submitted')}
-
{t('type')}
-
{t('image')}
-
{t('actions')}
-
- - {isQueueEmpty ? ( -
{t('queue_is_empty')}
- ) : hasMore ? ( - - ) : ( - <> - {filteredFeed.map((comment, index) => { - const commentCommunityAddress = getCommentCommunityAddress(comment); - const path = - addressToPathMap.get(commentCommunityAddress || '') ?? (commentCommunityAddress ? getBoardPath(commentCommunityAddress, directories) : undefined); - return ( - - ); - })} - {communityError?.message && feed.length === 0 && ( -
- -
- )} - - - )} - - )} - - {viewMode === 'compact' && isMobile && ( - <> - {isQueueEmpty ? ( -
{t('queue_is_empty')}
- ) : hasMore ? ( - - ) : ( - <> - {filteredFeed.map((comment) => { - const commentCommunityAddress = getCommentCommunityAddress(comment); - const path = - addressToPathMap.get(commentCommunityAddress || '') ?? (commentCommunityAddress ? getBoardPath(commentCommunityAddress, directories) : undefined); - return ( - - ); - })} - {communityError?.message && feed.length === 0 && ( -
- -
- )} - - - )} - - )} - - {viewMode === 'feed' && ( - <> - {isQueueEmpty ? ( -
{t('queue_is_empty')}
- ) : hasMore ? ( - } - components={footerComponents} - /> - ) : ( - <> - {filteredFeed.map((comment) => ( - - ))} - {communityError?.message && feed.length === 0 && ( -
- -
- )} - - - )} - - )} - - )} -
- {pageFooter} - + ); };