mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
perf(home): stop popular threads rerenders after load
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(' ', '').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);
|
||||
|
||||
Reference in New Issue
Block a user