fix(home): unify stats loading state with feed status string

Show a single centered loader without stat labels until all directory
stats resolve, using useFeedStateString like popular threads. Center
directory table status loading in its cell.
This commit is contained in:
Tommaso Casaburi
2026-05-29 17:47:47 +07:00
parent 45a113c621
commit a435d93c8a
4 changed files with 48 additions and 22 deletions
+9 -1
View File
@@ -145,8 +145,16 @@
word-break: break-all; word-break: break-all;
} }
.ownerCell { .ownerCell,
.statusCell {
text-align: center; text-align: center;
vertical-align: middle;
}
.statusLoading {
display: block;
width: fit-content;
margin-inline: auto;
} }
.ownerName { .ownerName {
+3 -1
View File
@@ -135,7 +135,9 @@ const DirectoryRow = ({ board, nowSeconds, rank, onVote }: DirectoryRowProps) =>
</Tooltip> </Tooltip>
</span> </span>
) : status === 'loading' ? ( ) : status === 'loading' ? (
<LoadingEllipsis string={t('loading')} /> <span className={styles.statusLoading}>
<LoadingEllipsis string={t('loading')} />
</span>
) : status === 'unknown' ? ( ) : status === 'unknown' ? (
<span className={styles.statusUnavailable}>{DIRECTORY_STATUS_UNAVAILABLE_MARKER}</span> <span className={styles.statusUnavailable}>{DIRECTORY_STATUS_UNAVAILABLE_MARKER}</span>
) : ( ) : (
+14 -2
View File
@@ -3,6 +3,7 @@ import { createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client'; import { createRoot, type Root } from 'react-dom/client';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useFeedStateString } from '../../../hooks/use-state-string';
import Home from '../home'; import Home from '../home';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -19,6 +20,7 @@ const testState = vi.hoisted(() => ({
navigateMock: vi.fn(), navigateMock: vi.fn(),
communities: {} as Record<string, unknown>, communities: {} as Record<string, unknown>,
communityStats: {} as Record<string, { allPostCount?: number; weekActiveUserCount?: number; state?: string }>, communityStats: {} as Record<string, { allPostCount?: number; weekActiveUserCount?: number; state?: string }>,
feedStateString: 'Downloading boards',
})); }));
vi.mock('react-i18next', () => ({ vi.mock('react-i18next', () => ({
@@ -76,6 +78,10 @@ vi.mock('../../../hooks/use-now-seconds', () => ({
useNowSeconds: () => testState.nowSeconds, useNowSeconds: () => testState.nowSeconds,
})); }));
vi.mock('../../../hooks/use-state-string', () => ({
useFeedStateString: vi.fn(() => testState.feedStateString),
}));
vi.mock('../../../components/loading-ellipsis', () => ({ vi.mock('../../../components/loading-ellipsis', () => ({
default: ({ string }: { string: string }) => createElement('span', { 'data-testid': 'loading-ellipsis' }, string), default: ({ string }: { string: string }) => createElement('span', { 'data-testid': 'loading-ellipsis' }, string),
})); }));
@@ -139,6 +145,7 @@ describe('Home', () => {
'music-posting.eth': { allPostCount: 5, weekActiveUserCount: 2 }, 'music-posting.eth': { allPostCount: 5, weekActiveUserCount: 2 },
'tech-posting.eth': { allPostCount: 7, weekActiveUserCount: 5 }, 'tech-posting.eth': { allPostCount: 7, weekActiveUserCount: 5 },
}; };
testState.feedStateString = 'Downloading boards';
container = document.createElement('div'); container = document.createElement('div');
document.body.appendChild(container); document.body.appendChild(container);
@@ -153,6 +160,7 @@ describe('Home', () => {
it('renders the home view chrome, child sections, collectors, and aggregated stats', () => { it('renders the home view chrome, child sections, collectors, and aggregated stats', () => {
renderHome(); renderHome();
expect(vi.mocked(useFeedStateString)).not.toHaveBeenCalled();
expect(document.title).toBe('5chan'); expect(document.title).toBe('5chan');
expect(container.querySelector('[data-testid="disclaimer-modal"]')?.textContent).toBe('disclaimer-modal'); expect(container.querySelector('[data-testid="disclaimer-modal"]')?.textContent).toBe('disclaimer-modal');
expect(container.querySelector('[data-testid="directory-modal"]')?.textContent).toBe('directory-modal'); expect(container.querySelector('[data-testid="directory-modal"]')?.textContent).toBe('directory-modal');
@@ -175,8 +183,12 @@ describe('Home', () => {
renderHome(); renderHome();
const loadingValues = Array.from(container.querySelectorAll('[data-testid="loading-ellipsis"]')); const loadingValues = Array.from(container.querySelectorAll('[data-testid="loading-ellipsis"]'));
expect(loadingValues).toHaveLength(3); expect(loadingValues).toHaveLength(1);
expect(loadingValues.map((value) => value.textContent)).toEqual(['loading', 'loading', 'loading']); expect(loadingValues[0]?.textContent).toBe('Downloading boards');
expect(vi.mocked(useFeedStateString)).toHaveBeenCalledWith(['music-posting.eth', 'tech-posting.eth']);
expect(container.textContent).not.toContain('total_posts');
expect(container.textContent).not.toContain('current_users');
expect(container.textContent).not.toContain('boards_tracked');
expect(container.textContent).not.toContain('total_posts 5'); expect(container.textContent).not.toContain('total_posts 5');
expect(container.textContent).not.toContain('current_users 2'); expect(container.textContent).not.toContain('current_users 2');
expect(container.textContent).not.toContain('boards_tracked 1'); expect(container.textContent).not.toContain('boards_tracked 1');
+22 -18
View File
@@ -9,6 +9,7 @@ import PopularThreadsBox from './popular-threads-box';
import BoardsList from './boards-list'; import BoardsList from './boards-list';
import SiteLegalMeta from '../../components/site-legal-meta'; import SiteLegalMeta from '../../components/site-legal-meta';
import LoadingEllipsis from '../../components/loading-ellipsis'; import LoadingEllipsis from '../../components/loading-ellipsis';
import { useFeedStateString } from '../../hooks/use-state-string';
import useDirectoryModalStore from '../../stores/use-directory-modal-store'; import useDirectoryModalStore from '../../stores/use-directory-modal-store';
import DisclaimerModal from '../../components/disclaimer-modal'; import DisclaimerModal from '../../components/disclaimer-modal';
import DirectoryModal from '../../components/directory-modal'; import DirectoryModal from '../../components/directory-modal';
@@ -88,19 +89,18 @@ const InfoBox = () => {
); );
}; };
interface StatValueProps {
isLoaded: boolean;
loadingLabel: string;
value: number;
}
type HomepageStats = { type HomepageStats = {
allPostCount?: number; allPostCount?: number;
weekActiveUserCount?: number; weekActiveUserCount?: number;
state?: string; state?: string;
}; };
const StatValue = ({ isLoaded, loadingLabel, value }: StatValueProps) => (isLoaded ? <>{value}</> : <LoadingEllipsis string={loadingLabel} />); const StatsLoading = ({ communityAddresses }: { communityAddresses: string[] }) => {
const { t } = useTranslation();
const loadingStateString = useFeedStateString(communityAddresses) || t('loading');
return <LoadingEllipsis string={loadingStateString} />;
};
const STATS_DIRECTORY_FALLBACK_DELAY_SECONDS = 30; const STATS_DIRECTORY_FALLBACK_DELAY_SECONDS = 30;
@@ -193,8 +193,6 @@ const Stats = ({ directories }: { directories: DirectoryCommunity[] }) => {
}; };
}, [communitiesStats, defaultDirectoryAddresses, directories, listsByCode]); }, [communitiesStats, defaultDirectoryAddresses, directories, listsByCode]);
const loadingLabel = t('loading');
return ( return (
<> <>
{/* Render collectors to fetch stats for each community */} {/* Render collectors to fetch stats for each community */}
@@ -206,15 +204,21 @@ const Stats = ({ directories }: { directories: DirectoryCommunity[] }) => {
<h2 className='capitalize'>{t('stats')}</h2> <h2 className='capitalize'>{t('stats')}</h2>
</div> </div>
<div className={`${styles.boxContent} ${styles.stats}`}> <div className={`${styles.boxContent} ${styles.stats}`}>
<div className={styles.stat}> {allDirectoryStatsLoaded ? (
<b>{t('total_posts')}</b> <StatValue isLoaded={allDirectoryStatsLoaded} loadingLabel={loadingLabel} value={totalPosts} /> <>
</div> <div className={styles.stat}>
<div className={styles.stat}> <b>{t('total_posts')}</b> {totalPosts}
<b>{t('current_users')}</b> <StatValue isLoaded={allDirectoryStatsLoaded} loadingLabel={loadingLabel} value={currentUsers} /> </div>
</div> <div className={styles.stat}>
<div className={styles.stat}> <b>{t('current_users')}</b> {currentUsers}
<b>{t('boards_tracked')}</b> <StatValue isLoaded={allDirectoryStatsLoaded} loadingLabel={loadingLabel} value={boardsTracked} /> </div>
</div> <div className={styles.stat}>
<b>{t('boards_tracked')}</b> {boardsTracked}
</div>
</>
) : (
<StatsLoading communityAddresses={collectorAddresses} />
)}
</div> </div>
</div> </div>
</> </>