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);
|
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.
|
* For visual updates like blinking animations, CSS handles that independently.
|
||||||
* This hook is for time-based calculations that don't need millisecond precision.
|
* 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);
|
const [currentTime, setCurrentTime] = useState(() => Date.now() / 1000);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (updateIntervalSeconds === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Update periodically
|
// Update periodically
|
||||||
const intervalId = setInterval(() => {
|
const intervalId = setInterval(() => {
|
||||||
setCurrentTime(Date.now() / 1000);
|
setCurrentTime(Date.now() / 1000);
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ function shuffleBoardAddresses(boardAddresses: string[]): string[] {
|
|||||||
return shuffledBoardAddresses;
|
return shuffledBoardAddresses;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPopularPostsInputKey(communityAddresses: string[]): string {
|
||||||
|
return [...communityAddresses].sort().join(',');
|
||||||
|
}
|
||||||
|
|
||||||
function getPopularPostsCacheEntry(inputKey: string, communityAddresses: string[]): PopularPostsCacheEntry {
|
function getPopularPostsCacheEntry(inputKey: string, communityAddresses: string[]): PopularPostsCacheEntry {
|
||||||
const cachedEntry = popularPostsCacheByInputKey.get(inputKey);
|
const cachedEntry = popularPostsCacheByInputKey.get(inputKey);
|
||||||
if (cachedEntry) {
|
if (cachedEntry) {
|
||||||
@@ -73,6 +77,11 @@ function getPopularPostsCacheEntry(inputKey: string, communityAddresses: string[
|
|||||||
return cacheEntry;
|
return cacheEntry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getRevealedPopularPosts(communityAddresses: string[]): Comment[] | undefined {
|
||||||
|
const cacheEntry = popularPostsCacheByInputKey.get(getPopularPostsInputKey(communityAddresses));
|
||||||
|
return cacheEntry?.revealed ? cacheEntry.posts : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function clearPopularPostsCacheForTest() {
|
export function clearPopularPostsCacheForTest() {
|
||||||
popularPostsCacheByInputKey.clear();
|
popularPostsCacheByInputKey.clear();
|
||||||
}
|
}
|
||||||
@@ -86,10 +95,10 @@ export function clearPopularPostsCacheForTest() {
|
|||||||
* the board filter, so threads never disappear during background loads.
|
* the board filter, so threads never disappear during background loads.
|
||||||
*/
|
*/
|
||||||
const usePopularPosts = (communities: Array<Community | undefined>, communityAddresses: string[]) => {
|
const usePopularPosts = (communities: Array<Community | undefined>, communityAddresses: string[]) => {
|
||||||
const inputKey = [...communityAddresses].sort().join(',');
|
const inputKey = getPopularPostsInputKey(communityAddresses);
|
||||||
const cacheEntry = getPopularPostsCacheEntry(inputKey, 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 nowSeconds = Math.floor(currentTime);
|
||||||
const loadingStartTimestamps = useCommunitiesLoadingStartTimestamps(communityAddresses);
|
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 { useTranslation } from 'react-i18next';
|
||||||
import { Comment, useCommunities } from '@bitsocialnet/bitsocial-react-hooks';
|
import { Comment, useCommunities } from '@bitsocialnet/bitsocial-react-hooks';
|
||||||
import styles from '../home.module.css';
|
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 { useFeedStateString } from '../../../hooks/use-state-string';
|
||||||
import usePopularThreadsOptionsStore from '../../../stores/use-popular-threads-options-store';
|
import usePopularThreadsOptionsStore from '../../../stores/use-popular-threads-options-store';
|
||||||
import { getCommentMediaInfo } from '../../../lib/utils/media-utils';
|
import { getCommentMediaInfo } from '../../../lib/utils/media-utils';
|
||||||
@@ -22,6 +22,13 @@ interface PopularThreadProps {
|
|||||||
boardPath: string;
|
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 ContentPreview = ({ content, maxLength = 99 }: { content: string; maxLength?: number }) => {
|
||||||
const plainText = removeMarkdown(content).trim().replaceAll(' ', '').replace(/\n\n/g, '\n').replaceAll('\n\n', '');
|
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;
|
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 PopularThreadsBox = ({ directories, directoryAddresses }: { directories: DirectoryCommunity[]; directoryAddresses: string[] }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { showWorksafeContentOnly, showNsfwContentOnly } = usePopularThreadsOptionsStore();
|
const { showWorksafeContentOnly, showNsfwContentOnly } = usePopularThreadsOptionsStore();
|
||||||
const directoryCommunities = useCommunityIdentifiers(directoryAddresses);
|
|
||||||
const { communities } = useCommunities({ communities: directoryCommunities });
|
|
||||||
|
|
||||||
const { filteredBoardAddresses, filteredCommunities } = useMemo(() => {
|
const filteredBoardAddresses = useMemo(() => {
|
||||||
const filteredEntries = directoryAddresses.flatMap((address, index) => {
|
return directoryAddresses.flatMap((address) => {
|
||||||
const directoryEntry = findDirectoryByAddress(directories, address);
|
const directoryEntry = findDirectoryByAddress(directories, address);
|
||||||
if (showWorksafeContentOnly && directoryEntry?.nsfw) {
|
if (showWorksafeContentOnly && directoryEntry?.nsfw) {
|
||||||
return [];
|
return [];
|
||||||
@@ -73,17 +78,16 @@ const PopularThreadsBox = ({ directories, directoryAddresses }: { directories: D
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [{ address, community: communities[index] }];
|
return [address];
|
||||||
});
|
});
|
||||||
|
}, [directories, directoryAddresses, showNsfwContentOnly, showWorksafeContentOnly]);
|
||||||
|
|
||||||
return {
|
const revealedPopularPosts = getRevealedPopularPosts(filteredBoardAddresses);
|
||||||
filteredBoardAddresses: filteredEntries.map((entry) => entry.address),
|
const shouldLoadPopularPosts = !revealedPopularPosts;
|
||||||
filteredCommunities: filteredEntries.map((entry) => entry.community),
|
const directoryCommunities = useCommunityIdentifiers(shouldLoadPopularPosts ? filteredBoardAddresses : []);
|
||||||
};
|
const { communities } = useCommunities({ communities: directoryCommunities });
|
||||||
}, [directories, directoryAddresses, showNsfwContentOnly, showWorksafeContentOnly, communities]);
|
|
||||||
|
|
||||||
const { popularPosts, isLoading } = usePopularPosts(filteredCommunities, filteredBoardAddresses);
|
const { popularPosts, isLoading } = usePopularPosts(shouldLoadPopularPosts ? communities : [], filteredBoardAddresses);
|
||||||
const loadingStateString = useFeedStateString(filteredBoardAddresses) || t('loading');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.box}>
|
<div className={styles.box}>
|
||||||
@@ -93,7 +97,7 @@ const PopularThreadsBox = ({ directories, directoryAddresses }: { directories: D
|
|||||||
</div>
|
</div>
|
||||||
<div className={`${styles.boxContent} ${styles.popularThreads} ${isLoading ? styles.popularThreadsLoading : ''}`}>
|
<div className={`${styles.boxContent} ${styles.popularThreads} ${isLoading ? styles.popularThreadsLoading : ''}`}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<LoadingEllipsis string={loadingStateString} />
|
<PopularThreadsLoading boardAddresses={filteredBoardAddresses} />
|
||||||
) : (
|
) : (
|
||||||
popularPosts.map((post: Comment) => {
|
popularPosts.map((post: Comment) => {
|
||||||
const communityAddress = getCommentCommunityAddress(post);
|
const communityAddress = getCommentCommunityAddress(post);
|
||||||
|
|||||||
Reference in New Issue
Block a user