From 608402b5c8bffa2da756896a0651393469853174 Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Thu, 4 Jun 2026 13:43:52 +0700 Subject: [PATCH] fix(board): prevent transient no threads state (#1151) * fix(board): prevent transient no threads state * fix(board): scope raw thread fallback by sort * fix(board): keep flash table loading during feed sync --- src/hooks/use-prune-hidden-catalog-threads.ts | 81 +++---------------- .../__tests__/raw-board-thread-state.test.ts | 63 +++++++++++++++ src/lib/utils/raw-board-thread-state.ts | 68 ++++++++++++++++ src/views/board/__tests__/board.test.tsx | 40 +++++++++ src/views/board/board.tsx | 33 ++++++-- 5 files changed, 210 insertions(+), 75 deletions(-) create mode 100644 src/lib/utils/__tests__/raw-board-thread-state.test.ts create mode 100644 src/lib/utils/raw-board-thread-state.ts diff --git a/src/hooks/use-prune-hidden-catalog-threads.ts b/src/hooks/use-prune-hidden-catalog-threads.ts index 845d1252..15de8ce9 100644 --- a/src/hooks/use-prune-hidden-catalog-threads.ts +++ b/src/hooks/use-prune-hidden-catalog-threads.ts @@ -1,10 +1,11 @@ import { useEffect, useMemo, useRef } from 'react'; -import { useAccount, type Comment, type CommunitiesPages, type Community } from '@bitsocial/bitsocial-react-hooks'; +import { useAccount, type Comment } from '@bitsocial/bitsocial-react-hooks'; import accountsStore from '@bitsocial/bitsocial-react-hooks/dist/stores/accounts'; import communitiesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities'; -import communitiesPagesStore, { getCommunityFirstPageCid, getCommunityPages } from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages'; +import communitiesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages'; import { getCommentCommunityAddress } from '../lib/utils/comment-utils'; import { isCommentArchived } from '../lib/utils/comment-moderation-utils'; +import { getRawBoardThreadState } from '../lib/utils/raw-board-thread-state'; import { useDirectories } from './use-directories'; import { getBoardAddressKeys, isBoardAddressInScope } from './use-hidden-catalog-threads'; @@ -15,72 +16,12 @@ type UsePruneHiddenCatalogThreadsOptions = { sortType: 'active' | 'new'; }; -type RawBoardCatalogState = { - isFullyLoaded: boolean; - rootThreadCids: Set; -}; - -const EMPTY_RAW_BOARD_CATALOG_STATE: RawBoardCatalogState = { - isFullyLoaded: false, - rootThreadCids: new Set(), -}; - -const addRootThreadCids = (cids: Set, comments: readonly Comment[] | undefined) => { - for (const comment of comments || []) { - if (comment?.cid && !comment.parentCid) { - cids.add(comment.cid); - } - } -}; - -const getRawBoardCatalogState = ({ - accountId, - communitiesPages, - community, - sortType, -}: { - accountId: string | undefined; - communitiesPages: CommunitiesPages; - community: Community | undefined; - sortType: 'active' | 'new'; -}): RawBoardCatalogState => { - if (!community) { - return EMPTY_RAW_BOARD_CATALOG_STATE; - } - - const rootThreadCids = new Set(); - const preloadedSortPage = community.posts?.pages?.[sortType]; - addRootThreadCids(rootThreadCids, preloadedSortPage?.comments); - - const firstPageCid = getCommunityFirstPageCid(community, sortType, 'posts'); - const pages = firstPageCid ? getCommunityPages(community, sortType, communitiesPages, 'posts', accountId) : []; - for (const page of pages) { - addRootThreadCids(rootThreadCids, page?.comments); - } - - if (pages.length > 0) { - return { - isFullyLoaded: !pages[pages.length - 1]?.nextCid, - rootThreadCids, - }; - } - - const pageCids = community.posts?.pageCids || {}; - const hasPageCids = Object.keys(pageCids).length > 0; - const preloadedPages = Object.values(community.posts?.pages || {}) as Array<{ comments?: Comment[]; nextCid?: string }>; - const hasCompletePreloadedPage = !hasPageCids && preloadedPages.some((page) => Array.isArray(page?.comments)) && preloadedPages.every((page) => !page?.nextCid); - - if (hasCompletePreloadedPage) { - for (const page of preloadedPages) { - addRootThreadCids(rootThreadCids, page?.comments); - } - } - - return { - isFullyLoaded: hasCompletePreloadedPage, - rootThreadCids, - }; -}; +const EMPTY_RAW_BOARD_THREAD_STATE = getRawBoardThreadState({ + accountId: undefined, + communitiesPages: {}, + community: undefined, + sortType: 'active', +}); const usePruneHiddenCatalogThreads = ({ enabled, hiddenThreadCandidates, communityAddress, sortType }: UsePruneHiddenCatalogThreadsOptions) => { const account = useAccount(); @@ -92,13 +33,13 @@ const usePruneHiddenCatalogThreads = ({ enabled, hiddenThreadCandidates, communi const rawBoardCatalogState = useMemo( () => enabled - ? getRawBoardCatalogState({ + ? getRawBoardThreadState({ accountId: account?.id, communitiesPages, community, sortType, }) - : EMPTY_RAW_BOARD_CATALOG_STATE, + : EMPTY_RAW_BOARD_THREAD_STATE, [account?.id, communitiesPages, community, enabled, sortType], ); diff --git a/src/lib/utils/__tests__/raw-board-thread-state.test.ts b/src/lib/utils/__tests__/raw-board-thread-state.test.ts new file mode 100644 index 00000000..ea9f5328 --- /dev/null +++ b/src/lib/utils/__tests__/raw-board-thread-state.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import type { Comment, CommunitiesPages, Community } from '@bitsocial/bitsocial-react-hooks'; +import { getRawBoardThreadState } from '../raw-board-thread-state'; + +const rootThread = (cid: string): Comment => + ({ + cid, + postCid: cid, + }) as Comment; + +describe('getRawBoardThreadState', () => { + it('keeps the preloaded fallback scoped to the requested sort type', () => { + const community = { + posts: { + pageCids: { + new: 'new-page-1', + }, + pages: { + active: { + comments: [], + }, + new: { + comments: [rootThread('new-thread')], + nextCid: 'new-page-2', + }, + }, + }, + } as Community; + + expect( + getRawBoardThreadState({ + accountId: undefined, + communitiesPages: {} as CommunitiesPages, + community, + sortType: 'active', + }), + ).toMatchObject({ + isFullyLoaded: true, + rootThreadCids: new Set(), + }); + }); + + it('treats an empty preloaded requested-sort page as a fully loaded empty board', () => { + const community = { + posts: { + pages: { + active: { + comments: [], + }, + }, + }, + } as Community; + + expect( + getRawBoardThreadState({ + accountId: undefined, + communitiesPages: {} as CommunitiesPages, + community, + sortType: 'active', + }).isFullyLoaded, + ).toBe(true); + }); +}); diff --git a/src/lib/utils/raw-board-thread-state.ts b/src/lib/utils/raw-board-thread-state.ts new file mode 100644 index 00000000..72e217a9 --- /dev/null +++ b/src/lib/utils/raw-board-thread-state.ts @@ -0,0 +1,68 @@ +import type { Comment, CommunitiesPages, Community } from '@bitsocial/bitsocial-react-hooks'; +import { getCommunityFirstPageCid, getCommunityPages } from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages'; + +export type RawBoardThreadState = { + isFullyLoaded: boolean; + rootThreadCids: Set; +}; + +const EMPTY_RAW_BOARD_THREAD_STATE: RawBoardThreadState = { + isFullyLoaded: false, + rootThreadCids: new Set(), +}; + +const addRootThreadCids = (cids: Set, comments: readonly Comment[] | undefined) => { + for (const comment of comments || []) { + if (comment?.cid && !comment.parentCid) { + cids.add(comment.cid); + } + } +}; + +export const getRawBoardThreadState = ({ + accountId, + communitiesPages, + community, + sortType, +}: { + accountId: string | undefined; + communitiesPages: CommunitiesPages; + community: Community | undefined; + sortType: 'active' | 'new'; +}): RawBoardThreadState => { + if (!community) { + return EMPTY_RAW_BOARD_THREAD_STATE; + } + + const rootThreadCids = new Set(); + const preloadedSortPage = community.posts?.pages?.[sortType]; + addRootThreadCids(rootThreadCids, preloadedSortPage?.comments); + + const firstPageCid = getCommunityFirstPageCid(community, sortType, 'posts'); + const pages = firstPageCid ? getCommunityPages(community, sortType, communitiesPages, 'posts', accountId) : []; + for (const page of pages) { + addRootThreadCids(rootThreadCids, page?.comments); + } + + if (pages.length > 0) { + return { + isFullyLoaded: !pages[pages.length - 1]?.nextCid, + rootThreadCids, + }; + } + + const hasPageCid = Boolean(community.posts?.pageCids?.[sortType]); + const preloadedPages = (preloadedSortPage ? [preloadedSortPage] : []) as Array<{ comments?: Comment[]; nextCid?: string }>; + const hasCompletePreloadedPage = !hasPageCid && preloadedPages.some((page) => Array.isArray(page?.comments)) && preloadedPages.every((page) => !page?.nextCid); + + if (hasCompletePreloadedPage) { + for (const page of preloadedPages) { + addRootThreadCids(rootThreadCids, page?.comments); + } + } + + return { + isFullyLoaded: hasCompletePreloadedPage, + rootThreadCids, + }; +}; diff --git a/src/views/board/__tests__/board.test.tsx b/src/views/board/__tests__/board.test.tsx index a2b163c6..2d7df360 100644 --- a/src/views/board/__tests__/board.test.tsx +++ b/src/views/board/__tests__/board.test.tsx @@ -3,6 +3,7 @@ import { createElement } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import communitiesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages'; import Board, { type BoardProps } from '../board'; import { clearStableLastVisitTimeFilterName, LAST_VISIT_STORAGE_KEY } from '../../../lib/utils/time-filter-utils'; @@ -35,6 +36,10 @@ type TestComment = { type TestCommunity = { error?: Error; nameResolved?: boolean; + posts?: { + pageCids?: Record; + pages?: Record; + }; shortAddress?: string; state?: string; title?: string; @@ -312,6 +317,19 @@ const flushEffects = async (count = 5) => { } }; +const markRawBoardThreadsFullyLoaded = (comments: TestComment[] = []) => { + testState.community = { + ...testState.community, + posts: { + pages: { + active: { + comments, + }, + }, + }, + }; +}; + const renderBoard = async ({ boardProps, initialEntry, @@ -393,6 +411,7 @@ describe('Board', () => { document.title = 'before'; clearStableLastVisitTimeFilterName(); localStorage.setItem(LAST_VISIT_STORAGE_KEY, String(Date.now())); + communitiesPagesStore.setState({ communitiesPages: {}, comments: {} }); Object.defineProperty(window, 'scrollTo', { configurable: true, value: vi.fn(), @@ -407,6 +426,7 @@ describe('Board', () => { afterEach(() => { act(() => root.unmount()); container.remove(); + communitiesPagesStore.setState({ communitiesPages: {}, comments: {} }); clearStableLastVisitTimeFilterName(); localStorage.clear(); }); @@ -622,6 +642,7 @@ describe('Board', () => { shortAddress: 'flash-posting.bso', title: '/f/ - Flash', }; + markRawBoardThreadsFullyLoaded(); await renderBoard({ initialEntry: '/f', routePath: '/:boardIdentifier/*' }); @@ -655,6 +676,7 @@ describe('Board', () => { title: '/f/ - Flash', }; testState.hasMore = true; + markRawBoardThreadsFullyLoaded(); await renderBoard({ initialEntry: '/f', routePath: '/:boardIdentifier/*' }); @@ -988,10 +1010,28 @@ describe('Board', () => { state: 'succeeded', title: '/mu/ - Music', }; + markRawBoardThreadsFullyLoaded(); await renderBoard({ initialEntry: '/mu', routePath: '/:boardIdentifier/*' }); expect(container.textContent).toContain('no_threads'); expect(container.querySelector('[data-testid="loading-ellipsis"]')).toBeNull(); }); + + it('does not show no threads after board metadata loads but raw thread pages are still missing', async () => { + testState.feedStateString = undefined; + testState.feedState = 'succeeded'; + testState.hasMore = false; + testState.community = { + error: undefined, + shortAddress: 'music-posting.eth', + state: 'succeeded', + title: '/mu/ - Music', + }; + + await renderBoard({ initialEntry: '/mu', routePath: '/:boardIdentifier/*' }); + + expect(container.textContent).not.toContain('no_threads'); + expect(container.querySelector('[data-testid="loading-ellipsis"]')?.textContent).toBe('downloading_board'); + }); }); diff --git a/src/views/board/board.tsx b/src/views/board/board.tsx index 2d067092..cb40bacf 100644 --- a/src/views/board/board.tsx +++ b/src/views/board/board.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'; import { Link, useLocation, useNavigate, useNavigationType, useParams } from 'react-router-dom'; import { Comment, useAccount, useAccountComments, useCommunity, useFeed } from '@bitsocial/bitsocial-react-hooks'; import { useCommunityField } from '../../hooks/use-stable-community'; +import communitiesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages'; import { Virtuoso, VirtuosoHandle, StateSnapshot } from 'react-virtuoso'; import { Trans, useTranslation } from 'react-i18next'; import styles from './board.module.css'; @@ -19,6 +20,7 @@ import usePostNumberStore from '../../stores/use-post-number-store'; import { useBoardFeedPageSize } from '../../hooks/use-board-feed-page-size'; import useExpandedTimeFilter from '../../hooks/use-expanded-time-filter'; import useIsMobile from '../../hooks/use-is-mobile'; +import { useNowSeconds } from '../../hooks/use-now-seconds'; import { useSuggestionFeedLoader } from '../../hooks/use-suggestion-feed-loader'; import useTimeFilter from '../../hooks/use-time-filter'; import { getPageSlice } from '../../lib/utils/board-feed-pagination'; @@ -26,6 +28,7 @@ import { getPageFromFeedPath, isDirectoryBoard, normalizeMultiboardFeedPath, str import { isCommentArchived } from '../../lib/utils/comment-moderation-utils'; import { getCommentCommunityAddress } from '../../lib/utils/comment-utils'; import { getNonokoPendingAccountCommentIndex } from '../../lib/utils/post-options-utils'; +import { getRawBoardThreadState } from '../../lib/utils/raw-board-thread-state'; import { getSearchWithTimeFilter, getTimeFilterSuggestion, type TimeFilterSuggestion } from '../../lib/utils/time-filter-utils'; import { getPretextItemSizeFromElement, resolveFeedVirtualizationMode } from '../../lib/utils/pretext-height-estimates'; import { isFlashDirectory, isFlashDirectoryCode } from '../../lib/flash-tags'; @@ -45,6 +48,7 @@ const MONTH_IN_SECONDS = 30 * 24 * 60 * 60; const YEAR_IN_SECONDS = 365 * 24 * 60 * 60; // Keep the hook on its indexed fast path when this view should not inject local posts. const EMPTY_ACCOUNT_COMMENT_LOOKUP = { commentIndices: [-1] }; +const EMPTY_COMMUNITIES_PAGES = {}; /** Board feed always uses 'active' sort; catalog dropdown does not affect board ordering. */ const BOARD_SORT_TYPE = 'active' as const; @@ -55,6 +59,7 @@ interface BoardFooterProps { feedState: string | undefined; combinedFeedLength: number; isSingleCommunityBoard: boolean; + isRawBoardThreadStateFullyLoaded: boolean; isInSubscriptionsView: boolean; isInModView: boolean; currentTimeFilterName: string; @@ -78,6 +83,7 @@ const BoardFooter = ({ feedState, combinedFeedLength, isSingleCommunityBoard, + isRawBoardThreadStateFullyLoaded, isInSubscriptionsView, isInModView, currentTimeFilterName, @@ -96,7 +102,7 @@ const BoardFooter = ({ const isLoadedCommunityState = communityState === 'succeeded' || communityState === 'ready'; const isFeedSucceeded = feedState === 'succeeded'; const isFeedFailed = feedState === 'failed'; - const canShowNoThreads = isSingleCommunityBoard ? isLoadedCommunityState && isFeedSucceeded : isFeedSucceeded && !hasMore; + const canShowNoThreads = isSingleCommunityBoard ? isLoadedCommunityState && isFeedSucceeded && isRawBoardThreadStateFullyLoaded : isFeedSucceeded && !hasMore; const isEmptyFeedLoading = combinedFeedLength === 0 && !canShowNoThreads && (isSingleCommunityBoard ? communityState !== 'failed' : !isFeedFailed); const showFooterLoading = showLoadingEllipsis && (hasMore || isEmptyFeedLoading); @@ -306,6 +312,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t [communityAddress], ); const { accountComments: recentAccountComments } = useAccountComments(accountCommentLookupOptions); + const nowSeconds = useNowSeconds(recentAccountComments.length > 0); const nonokoPendingAccountCommentIndex = getNonokoPendingAccountCommentIndex(routerLocation.state); const nonokoPendingAccountCommentLookupOptions = useMemo( () => @@ -353,7 +360,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t return ( !deleted && !removed && - timestamp > Date.now() / 1000 - RECENT_ACCOUNT_COMMENT_WINDOW_SECONDS && + timestamp > nowSeconds - RECENT_ACCOUNT_COMMENT_WINDOW_SECONDS && state === 'succeeded' && cid && cid === postCid && @@ -361,7 +368,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t !feedCids.has(cid) ); }), - [recentAccountComments, communityAddress, feedCids], + [recentAccountComments, communityAddress, feedCids, nowSeconds], ); const localAccountComments = useMemo(() => { if (!nonokoPendingAccountComment) return filteredComments; @@ -451,6 +458,20 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t // useCommunityField only reads from store, doesn't trigger fetching const communityData = useCommunity(communityIdentifier ? { community: communityIdentifier } : undefined); const { error: communityError, state: communityState } = communityData || {}; + const communitiesPages = communitiesPagesStore((state) => (isMultiboardView ? EMPTY_COMMUNITIES_PAGES : state.communitiesPages)); + const rawBoardThreadState = useMemo( + () => + isMultiboardView + ? undefined + : getRawBoardThreadState({ + accountId: account?.id, + communitiesPages, + community: communityData, + sortType: BOARD_SORT_TYPE, + }), + [account?.id, communitiesPages, communityData, isMultiboardView], + ); + const isRawBoardThreadStateFullyLoaded = rawBoardThreadState?.isFullyLoaded ?? false; const title = isInAllView ? t('all') : isInSubscriptionsView ? t('subscriptions') : isInModView ? t('mod') : communityTitle; // Memoize footer component to preserve identity across renders (Virtuoso optimization) @@ -466,6 +487,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t feedState={feedState} combinedFeedLength={combinedFeed.length} isSingleCommunityBoard={!isInAllView && !isInSubscriptionsView && !isInModView} + isRawBoardThreadStateFullyLoaded={isRawBoardThreadStateFullyLoaded} isInSubscriptionsView={isInSubscriptionsView} isInModView={isInModView} currentTimeFilterName={currentTimeFilterName} @@ -546,6 +568,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t communityAddresses, hasMore, combinedFeed.length, + isRawBoardThreadStateFullyLoaded, isInAllView, isInSubscriptionsView, isInModView, @@ -644,8 +667,8 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t const displayFeed = effectiveInfiniteScroll ? combinedFeed : currentPageFeed; const isLoadedCommunityState = communityState === 'succeeded' || communityState === 'ready'; const isFeedSucceeded = feedState === 'succeeded'; - const shouldShowFlashTableLoading = - shouldUseFlashTable && displayFeed.length === 0 && !(isLoadedCommunityState && isFeedSucceeded) && communityState !== 'failed' && feedState !== 'failed'; + const canShowEmptyFlashTable = isLoadedCommunityState && isFeedSucceeded && isRawBoardThreadStateFullyLoaded; + const shouldShowFlashTableLoading = shouldUseFlashTable && displayFeed.length === 0 && !canShowEmptyFlashTable && communityState !== 'failed' && feedState !== 'failed'; return ( <>