diff --git a/docs/agent-runs/popular-threads-rerenders/feature-list.json b/docs/agent-runs/popular-threads-rerenders/feature-list.json new file mode 100644 index 00000000..2f7ed7f1 --- /dev/null +++ b/docs/agent-runs/popular-threads-rerenders/feature-list.json @@ -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." + } + ] +} diff --git a/docs/agent-runs/popular-threads-rerenders/progress.md b/docs/agent-runs/popular-threads-rerenders/progress.md new file mode 100644 index 00000000..4dfc0adf --- /dev/null +++ b/docs/agent-runs/popular-threads-rerenders/progress.md @@ -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. diff --git a/src/hooks/__tests__/browser-hooks.test.tsx b/src/hooks/__tests__/browser-hooks.test.tsx index 56588913..015d272f 100644 --- a/src/hooks/__tests__/browser-hooks.test.tsx +++ b/src/hooks/__tests__/browser-hooks.test.tsx @@ -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); + }); }); diff --git a/src/hooks/use-current-time.ts b/src/hooks/use-current-time.ts index 959b91f8..80f61dba 100644 --- a/src/hooks/use-current-time.ts +++ b/src/hooks/use-current-time.ts @@ -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); diff --git a/src/hooks/use-popular-posts.ts b/src/hooks/use-popular-posts.ts index 00ebcc23..d36fc20f 100644 --- a/src/hooks/use-popular-posts.ts +++ b/src/hooks/use-popular-posts.ts @@ -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, 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); diff --git a/src/views/home/popular-threads-box/__tests__/popular-threads-box.test.tsx b/src/views/home/popular-threads-box/__tests__/popular-threads-box.test.tsx new file mode 100644 index 00000000..02ca5de0 --- /dev/null +++ b/src/views/home/popular-threads-box/__tests__/popular-threads-box.test.tsx @@ -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 | Promise }).act as (cb: () => void | Promise) => void | Promise; + +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']); + }); +}); diff --git a/src/views/home/popular-threads-box/popular-threads-box.tsx b/src/views/home/popular-threads-box/popular-threads-box.tsx index f070a2eb..2bd70adf 100644 --- a/src/views/home/popular-threads-box/popular-threads-box.tsx +++ b/src/views/home/popular-threads-box/popular-threads-box.tsx @@ -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 ; +}; + const ContentPreview = ({ content, maxLength = 99 }: { content: string; maxLength?: number }) => { const plainText = removeMarkdown(content).trim().replaceAll(' ', '').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 (
@@ -93,7 +97,7 @@ const PopularThreadsBox = ({ directories, directoryAddresses }: { directories: D
{isLoading ? ( - + ) : ( popularPosts.map((post: Comment) => { const communityAddress = getCommentCommunityAddress(post);