perf(home): stop popular threads rerenders after load

This commit is contained in:
Tommaso Casaburi
2026-04-19 15:01:27 +07:00
parent 508fffe5d6
commit 4b06ac8c9a
7 changed files with 249 additions and 17 deletions
@@ -0,0 +1,29 @@
{
"task": "popular-threads-rerenders",
"last_updated": "2026-04-19",
"items": [
{
"id": "F001",
"priority": 1,
"status": "completed",
"description": "Reduce unnecessary rerenders in the popular threads box after the popular-thread algorithm finishes and the loaded list is stable until hard refresh.",
"verification": [
"./scripts/agent-init.sh --smoke",
"profile popular threads rerender behavior with react-scan",
"yarn build",
"yarn lint",
"yarn type-check",
"yarn doctor",
"playwright-cli browser checks across Blink, Gecko, and WebKit"
],
"files": [
"src/hooks/use-current-time.ts",
"src/hooks/use-popular-posts.ts",
"src/hooks/__tests__/browser-hooks.test.tsx",
"src/views/home/popular-threads-box/popular-threads-box.tsx",
"src/views/home/popular-threads-box/__tests__/popular-threads-box.test.tsx"
],
"notes": "Post-fix profile measured 0 React commits and no PopularThreadsBox/PopularThreadCard/CatalogPostMedia/ContentPreview rerenders in the 10s window after Popular Threads became visible."
}
]
}
@@ -0,0 +1,21 @@
# Progress Log
Append one entry per session.
## 2026-04-19 00:00
- Item: F001
- Summary: Created a fresh worktree and initialized task tracking before profiling the popular threads rerender issue.
- Files: `docs/agent-runs/popular-threads-rerenders/feature-list.json`, `docs/agent-runs/popular-threads-rerenders/progress.md`
- Verification: pending
- Blockers: none
- Next: Run baseline smoke check, profile the affected views, identify the popular threads source components, then implement the scoped rerender fix.
## 2026-04-19 14:56
- Item: F001
- Summary: Detached Popular Threads from feed-state and community subscriptions after the popular-post cache is revealed, and disabled timer updates for frozen current-time consumers.
- Files: `src/hooks/use-current-time.ts`, `src/hooks/use-popular-posts.ts`, `src/hooks/__tests__/browser-hooks.test.tsx`, `src/views/home/popular-threads-box/popular-threads-box.tsx`, `src/views/home/popular-threads-box/__tests__/popular-threads-box.test.tsx`
- Verification: `./scripts/agent-init.sh --smoke` with branch-scoped `AGENT_APP_URL`, `yarn test`, targeted Vitest files, `yarn build`, `yarn lint`, `yarn type-check`, `yarn doctor`, `yarn knip`, pre/post profile-browsing runs, `playwright-cli` desktop and mobile checks in Chrome, Firefox, and WebKit
- Blockers: none
- Next: Review and commit the completed task branch.
@@ -82,4 +82,19 @@ describe('browser hooks', () => {
expect(latestValue).toBe(1_704_067_230);
});
it('can leave the current time frozen without scheduling updates', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-01T00:00:00Z'));
expect(renderHookValue(() => useCurrentTime(false))).toBe(1_704_067_200);
act(() => {
vi.setSystemTime(new Date('2024-01-01T00:01:00Z'));
vi.advanceTimersByTime(60_000);
});
expect(latestValue).toBe(1_704_067_200);
expect(vi.getTimerCount()).toBe(0);
});
});
+5 -1
View File
@@ -8,10 +8,14 @@ import { useState, useEffect } from 'react';
* For visual updates like blinking animations, CSS handles that independently.
* This hook is for time-based calculations that don't need millisecond precision.
*/
export const useCurrentTime = (updateIntervalSeconds = 60) => {
export const useCurrentTime = (updateIntervalSeconds: number | false = 60) => {
const [currentTime, setCurrentTime] = useState(() => Date.now() / 1000);
useEffect(() => {
if (updateIntervalSeconds === false) {
return;
}
// Update periodically
const intervalId = setInterval(() => {
setCurrentTime(Date.now() / 1000);
+11 -2
View File
@@ -58,6 +58,10 @@ function shuffleBoardAddresses(boardAddresses: string[]): string[] {
return shuffledBoardAddresses;
}
function getPopularPostsInputKey(communityAddresses: string[]): string {
return [...communityAddresses].sort().join(',');
}
function getPopularPostsCacheEntry(inputKey: string, communityAddresses: string[]): PopularPostsCacheEntry {
const cachedEntry = popularPostsCacheByInputKey.get(inputKey);
if (cachedEntry) {
@@ -73,6 +77,11 @@ function getPopularPostsCacheEntry(inputKey: string, communityAddresses: string[
return cacheEntry;
}
export function getRevealedPopularPosts(communityAddresses: string[]): Comment[] | undefined {
const cacheEntry = popularPostsCacheByInputKey.get(getPopularPostsInputKey(communityAddresses));
return cacheEntry?.revealed ? cacheEntry.posts : undefined;
}
export function clearPopularPostsCacheForTest() {
popularPostsCacheByInputKey.clear();
}
@@ -86,10 +95,10 @@ export function clearPopularPostsCacheForTest() {
* the board filter, so threads never disappear during background loads.
*/
const usePopularPosts = (communities: Array<Community | undefined>, communityAddresses: string[]) => {
const inputKey = [...communityAddresses].sort().join(',');
const inputKey = getPopularPostsInputKey(communityAddresses);
const cacheEntry = getPopularPostsCacheEntry(inputKey, communityAddresses);
const currentTime = useCurrentTime(cacheEntry.revealed ? 300 : 5);
const currentTime = useCurrentTime(cacheEntry.revealed ? false : 5);
const nowSeconds = Math.floor(currentTime);
const loadingStartTimestamps = useCommunitiesLoadingStartTimestamps(communityAddresses);
@@ -0,0 +1,150 @@
import * as React from 'react';
import { createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useFeedStateString } from '../../../../hooks/use-state-string';
import PopularThreadsBox from '../popular-threads-box';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
const testState = vi.hoisted(() => ({
communities: [] as unknown[],
feedStateString: 'Downloading boards',
isLoading: false,
requestedCommunityIdentifiers: [] as string[][],
requestedCommunities: [] as unknown[][],
revealedPopularPosts: undefined as
| Array<{
cid: string;
communityAddress: string;
content: string;
link: string;
thumbnailUrl: string;
title: string;
}>
| undefined,
popularPosts: [] as Array<{
cid: string;
communityAddress: string;
content: string;
link: string;
thumbnailUrl: string;
title: string;
}>,
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({
useCommunities: ({ communities }: { communities: unknown[] }) => {
testState.requestedCommunities.push(communities);
return { communities: testState.communities };
},
}));
vi.mock('../../../../hooks/use-community-identifiers', () => ({
useCommunityIdentifiers: (addresses?: string[]) => {
testState.requestedCommunityIdentifiers.push(addresses || []);
return addresses || [];
},
}));
vi.mock('../../../../hooks/use-state-string', () => ({
useFeedStateString: vi.fn(() => testState.feedStateString),
}));
vi.mock('../../../../hooks/use-popular-posts', () => ({
getRevealedPopularPosts: vi.fn(() => testState.revealedPopularPosts),
default: vi.fn(() => ({
error: null,
isLoading: testState.isLoading,
popularPosts: testState.popularPosts,
})),
}));
vi.mock('../../../../components/catalog-row', () => ({
CatalogPostMedia: ({ cid }: { cid: string }) => createElement('div', { 'data-testid': 'popular-thread-media' }, cid),
}));
vi.mock('../../box-modal', () => ({
default: () => createElement('button', { 'aria-label': 'filters' }),
}));
const directories = [
{ address: 'music-posting.eth', title: '/mu/ - Music' },
{ address: 'tech-posting.eth', title: '/g/ - Technology' },
];
let container: HTMLDivElement;
let root: Root;
const renderPopularThreadsBox = () => {
act(() => {
root.render(createElement(MemoryRouter, {}, createElement(PopularThreadsBox, { directories, directoryAddresses: directories.map((entry) => entry.address) })));
});
};
describe('PopularThreadsBox', () => {
beforeEach(() => {
vi.clearAllMocks();
testState.communities = directories.map((entry) => ({ address: entry.address }));
testState.feedStateString = 'Downloading boards';
testState.isLoading = false;
testState.requestedCommunityIdentifiers = [];
testState.requestedCommunities = [];
testState.revealedPopularPosts = undefined;
testState.popularPosts = [
{
cid: 'thread-1',
communityAddress: 'music-posting.eth',
content: 'thread content',
link: 'https://cdn.example/thread-1.jpg',
thumbnailUrl: 'https://cdn.example/thread-1-thumb.jpg',
title: 'thread title',
},
];
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it('does not subscribe to feed state once popular threads are visible', () => {
renderPopularThreadsBox();
expect(container.textContent).toContain('Music');
expect(container.textContent).toContain('thread title');
expect(vi.mocked(useFeedStateString)).not.toHaveBeenCalled();
});
it('does not subscribe to board communities once the popular posts cache is revealed', () => {
testState.revealedPopularPosts = testState.popularPosts;
renderPopularThreadsBox();
expect(testState.requestedCommunityIdentifiers).toEqual([[]]);
expect(testState.requestedCommunities).toEqual([[]]);
expect(container.textContent).toContain('thread title');
});
it('subscribes to feed state only while popular threads are loading', () => {
testState.isLoading = true;
testState.popularPosts = [];
renderPopularThreadsBox();
expect(container.textContent).toContain('Downloading boards');
expect(vi.mocked(useFeedStateString)).toHaveBeenCalledWith(['music-posting.eth', 'tech-posting.eth']);
});
});
@@ -3,7 +3,7 @@ import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Comment, useCommunities } from '@bitsocialnet/bitsocial-react-hooks';
import styles from '../home.module.css';
import usePopularPosts from '../../../hooks/use-popular-posts';
import usePopularPosts, { getRevealedPopularPosts } from '../../../hooks/use-popular-posts';
import { useFeedStateString } from '../../../hooks/use-state-string';
import usePopularThreadsOptionsStore from '../../../stores/use-popular-threads-options-store';
import { getCommentMediaInfo } from '../../../lib/utils/media-utils';
@@ -22,6 +22,13 @@ interface PopularThreadProps {
boardPath: string;
}
const PopularThreadsLoading = ({ boardAddresses }: { boardAddresses: string[] }) => {
const { t } = useTranslation();
const loadingStateString = useFeedStateString(boardAddresses) || t('loading');
return <LoadingEllipsis string={loadingStateString} />;
};
const ContentPreview = ({ content, maxLength = 99 }: { content: string; maxLength?: number }) => {
const plainText = removeMarkdown(content).trim().replaceAll('&nbsp;', '').replace(/\n\n/g, '\n').replaceAll('\n\n', '');
const truncatedText = plainText.length > maxLength ? `${plainText.substring(0, maxLength).trim()}...` : plainText;
@@ -60,11 +67,9 @@ const PopularThreadCard = memo(
const PopularThreadsBox = ({ directories, directoryAddresses }: { directories: DirectoryCommunity[]; directoryAddresses: string[] }) => {
const { t } = useTranslation();
const { showWorksafeContentOnly, showNsfwContentOnly } = usePopularThreadsOptionsStore();
const directoryCommunities = useCommunityIdentifiers(directoryAddresses);
const { communities } = useCommunities({ communities: directoryCommunities });
const { filteredBoardAddresses, filteredCommunities } = useMemo(() => {
const filteredEntries = directoryAddresses.flatMap((address, index) => {
const filteredBoardAddresses = useMemo(() => {
return directoryAddresses.flatMap((address) => {
const directoryEntry = findDirectoryByAddress(directories, address);
if (showWorksafeContentOnly && directoryEntry?.nsfw) {
return [];
@@ -73,17 +78,16 @@ const PopularThreadsBox = ({ directories, directoryAddresses }: { directories: D
return [];
}
return [{ address, community: communities[index] }];
return [address];
});
}, [directories, directoryAddresses, showNsfwContentOnly, showWorksafeContentOnly]);
return {
filteredBoardAddresses: filteredEntries.map((entry) => entry.address),
filteredCommunities: filteredEntries.map((entry) => entry.community),
};
}, [directories, directoryAddresses, showNsfwContentOnly, showWorksafeContentOnly, communities]);
const revealedPopularPosts = getRevealedPopularPosts(filteredBoardAddresses);
const shouldLoadPopularPosts = !revealedPopularPosts;
const directoryCommunities = useCommunityIdentifiers(shouldLoadPopularPosts ? filteredBoardAddresses : []);
const { communities } = useCommunities({ communities: directoryCommunities });
const { popularPosts, isLoading } = usePopularPosts(filteredCommunities, filteredBoardAddresses);
const loadingStateString = useFeedStateString(filteredBoardAddresses) || t('loading');
const { popularPosts, isLoading } = usePopularPosts(shouldLoadPopularPosts ? communities : [], filteredBoardAddresses);
return (
<div className={styles.box}>
@@ -93,7 +97,7 @@ const PopularThreadsBox = ({ directories, directoryAddresses }: { directories: D
</div>
<div className={`${styles.boxContent} ${styles.popularThreads} ${isLoading ? styles.popularThreadsLoading : ''}`}>
{isLoading ? (
<LoadingEllipsis string={loadingStateString} />
<PopularThreadsLoading boardAddresses={filteredBoardAddresses} />
) : (
popularPosts.map((post: Comment) => {
const communityAddress = getCommentCommunityAddress(post);