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
This commit is contained in:
Tommaso Casaburi
2026-06-04 13:43:52 +07:00
committed by GitHub
parent 49d6723225
commit 608402b5c8
5 changed files with 210 additions and 75 deletions
+11 -70
View File
@@ -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<string>;
};
const EMPTY_RAW_BOARD_CATALOG_STATE: RawBoardCatalogState = {
isFullyLoaded: false,
rootThreadCids: new Set<string>(),
};
const addRootThreadCids = (cids: Set<string>, 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<string>();
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],
);
@@ -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<string>(),
});
});
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);
});
});
+68
View File
@@ -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<string>;
};
const EMPTY_RAW_BOARD_THREAD_STATE: RawBoardThreadState = {
isFullyLoaded: false,
rootThreadCids: new Set<string>(),
};
const addRootThreadCids = (cids: Set<string>, 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<string>();
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,
};
};
+40
View File
@@ -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<string, string>;
pages?: Record<string, { comments?: TestComment[]; nextCid?: string }>;
};
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');
});
});
+28 -5
View File
@@ -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 (
<>