fix(home): preserve popular threads during navigation

This commit is contained in:
Tommaso Casaburi
2026-04-19 14:24:35 +07:00
parent f2ab25bd08
commit 508fffe5d6
2 changed files with 69 additions and 31 deletions
+33 -3
View File
@@ -2,7 +2,7 @@ import * as React from 'react';
import { createElement } from 'react'; import { createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client'; import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import usePopularPosts from '../use-popular-posts'; import usePopularPosts, { clearPopularPostsCacheForTest } from '../use-popular-posts';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; (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 act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
@@ -90,6 +90,7 @@ describe('usePopularPosts', () => {
testState.currentTime = 1_704_067_200; testState.currentTime = 1_704_067_200;
testState.loadingTimestamps = []; testState.loadingTimestamps = [];
testState.requestedAddresses = undefined; testState.requestedAddresses = undefined;
clearPopularPostsCacheForTest();
latestValue = { latestValue = {
error: null, error: null,
@@ -185,7 +186,7 @@ describe('usePopularPosts', () => {
expect(latestValue.popularPosts).toEqual([]); expect(latestValue.popularPosts).toEqual([]);
}); });
it('reshuffles the selected boards on each mount while keeping one top thread per board', async () => { it('keeps selected boards across remounts while the page module stays loaded', async () => {
const addresses = Array.from({ length: 10 }, (_, index) => `board-${index}.eth`); const addresses = Array.from({ length: 10 }, (_, index) => `board-${index}.eth`);
const communities = addresses.map((address, index) => createCommunity(address, [createPost(address, 'top', 30 - index), createPost(address, 'backup', 10 - index)])); const communities = addresses.map((address, index) => createCommunity(address, [createPost(address, 'top', 30 - index), createPost(address, 'backup', 10 - index)]));
const keepOrderRandom = mockRandomSequence(Array.from({ length: addresses.length - 1 }, () => 0.999_999)); const keepOrderRandom = mockRandomSequence(Array.from({ length: addresses.length - 1 }, () => 0.999_999));
@@ -203,8 +204,37 @@ describe('usePopularPosts', () => {
await renderHook(addresses, communities); await renderHook(addresses, communities);
expect(latestValue.isLoading).toBe(false); expect(latestValue.isLoading).toBe(false);
expect(latestValue.popularPosts.map((post) => post.communityAddress)).toEqual(addresses.slice(1, 9)); expect(latestValue.popularPosts.map((post) => post.communityAddress)).toEqual(addresses.slice(0, 8));
expect(latestValue.popularPosts.every((post) => post.cid.endsWith('-top'))).toBe(true); expect(latestValue.popularPosts.every((post) => post.cid.endsWith('-top'))).toBe(true);
expect(rotateOrderRandom).not.toHaveBeenCalled();
rotateOrderRandom.mockRestore();
});
it('recalculates popular posts after the page cache is cleared like a browser reload', async () => {
const addresses = Array.from({ length: 10 }, (_, index) => `board-${index}.eth`);
const initialCommunities = addresses.map((address, index) =>
createCommunity(address, [createPost(address, 'top', 30 - index), createPost(address, 'backup', 10 - index)]),
);
const keepOrderRandom = mockRandomSequence(Array.from({ length: addresses.length - 1 }, () => 0.999_999));
await renderHook(addresses, initialCommunities);
expect(latestValue.isLoading).toBe(false);
expect(latestValue.popularPosts.map((post) => post.communityAddress)).toEqual(addresses.slice(0, 8));
expect(latestValue.popularPosts.every((post) => post.cid.endsWith('-top'))).toBe(true);
keepOrderRandom.mockRestore();
clearPopularPostsCacheForTest();
resetHookRoot();
const refreshedCommunities = addresses.map((address, index) => createCommunity(address, [createPost(address, 'top', 1), createPost(address, 'reload', 100 - index)]));
const rotateOrderRandom = mockRandomSequence(Array.from({ length: addresses.length - 1 }, () => 0));
await renderHook(addresses, refreshedCommunities);
expect(latestValue.isLoading).toBe(false);
expect(latestValue.popularPosts.map((post) => post.communityAddress)).toEqual(addresses.slice(1, 9));
expect(latestValue.popularPosts.map((post) => post.cid)).toEqual(addresses.slice(1, 9).map((address) => `${address}-reload`));
rotateOrderRandom.mockRestore(); rotateOrderRandom.mockRestore();
}); });
+36 -28
View File
@@ -1,4 +1,4 @@
import { useMemo, useRef } from 'react'; import { useMemo } from 'react';
import { Comment, type Community } from '@bitsocialnet/bitsocial-react-hooks'; import { Comment, type Community } from '@bitsocialnet/bitsocial-react-hooks';
import { getCommentMediaInfo, getHasThumbnail } from '../lib/utils/media-utils'; import { getCommentMediaInfo, getHasThumbnail } from '../lib/utils/media-utils';
import useCommunitiesLoadingStartTimestamps from '../stores/use-communities-loading-start-timestamps-store'; import useCommunitiesLoadingStartTimestamps from '../stores/use-communities-loading-start-timestamps-store';
@@ -15,11 +15,14 @@ type PopularPostCandidate = {
post: Comment; post: Comment;
}; };
type CommittedPopularPosts = { type PopularPostsCacheEntry = {
randomizedBoardAddresses: string[];
posts: Comment[]; posts: Comment[];
revealed: boolean; revealed: boolean;
}; };
const popularPostsCacheByInputKey = new Map<string, PopularPostsCacheEntry>();
/** /**
* Time-decayed popularity: replyCount divided by age of latest * Time-decayed popularity: replyCount divided by age of latest
* activity so a stale post with many old replies loses to a newer * activity so a stale post with many old replies loses to a newer
@@ -55,38 +58,43 @@ function shuffleBoardAddresses(boardAddresses: string[]): string[] {
return shuffledBoardAddresses; return shuffledBoardAddresses;
} }
function getPopularPostsCacheEntry(inputKey: string, communityAddresses: string[]): PopularPostsCacheEntry {
const cachedEntry = popularPostsCacheByInputKey.get(inputKey);
if (cachedEntry) {
return cachedEntry;
}
const cacheEntry = {
randomizedBoardAddresses: shuffleBoardAddresses(communityAddresses),
posts: [],
revealed: false,
};
popularPostsCacheByInputKey.set(inputKey, cacheEntry);
return cacheEntry;
}
export function clearPopularPostsCacheForTest() {
popularPostsCacheByInputKey.clear();
}
/** /**
* Each board contributes at most one time-decayed popular thread, but the * Each board contributes at most one time-decayed popular thread, but the
* board order is shuffled on mount so repeat visits surface different boards. * board order is shuffled once per page load so repeat navigation keeps the
* same threads until a real browser refresh creates a fresh module instance.
* *
* The first revealed set is frozen until the user refreshes or changes * The first revealed set is frozen until the user refreshes or changes
* 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 = [...communityAddresses].sort().join(',');
const committedRef = useRef<CommittedPopularPosts>({ const cacheEntry = getPopularPostsCacheEntry(inputKey, communityAddresses);
posts: [],
revealed: false,
});
const prevInputKeyRef = useRef('');
const randomizedBoardAddressesRef = useRef<string[]>([]);
// Reset committed and reshuffle when the requested board set changes (e.g. NSFW filter toggle). const currentTime = useCurrentTime(cacheEntry.revealed ? 300 : 5);
if (prevInputKeyRef.current !== inputKey) {
prevInputKeyRef.current = inputKey;
randomizedBoardAddressesRef.current = shuffleBoardAddresses(communityAddresses);
committedRef.current = {
posts: [],
revealed: false,
};
}
const currentTime = useCurrentTime(committedRef.current.revealed ? 300 : 5);
const nowSeconds = Math.floor(currentTime); const nowSeconds = Math.floor(currentTime);
const loadingStartTimestamps = useCommunitiesLoadingStartTimestamps(communityAddresses); const loadingStartTimestamps = useCommunitiesLoadingStartTimestamps(communityAddresses);
const candidates = useMemo<PopularPostCandidate[]>(() => { const candidates = useMemo<PopularPostCandidate[]>(() => {
if (committedRef.current.revealed || committedRef.current.posts.length >= MAX_POSTS) { if (cacheEntry.revealed || cacheEntry.posts.length >= MAX_POSTS) {
return []; return [];
} }
@@ -95,7 +103,7 @@ const usePopularPosts = (communities: Array<Community | undefined>, communityAdd
const allPosts: PopularPostCandidate[] = []; const allPosts: PopularPostCandidate[] = [];
const communitiesByAddress = new Map(communityAddresses.map((boardAddress, index) => [boardAddress, communities[index]])); const communitiesByAddress = new Map(communityAddresses.map((boardAddress, index) => [boardAddress, communities[index]]));
randomizedBoardAddressesRef.current.forEach((boardAddress) => { cacheEntry.randomizedBoardAddresses.forEach((boardAddress) => {
const community = communitiesByAddress.get(boardAddress); const community = communitiesByAddress.get(boardAddress);
if (!boardAddress || !community?.posts?.pages?.hot?.comments) { if (!boardAddress || !community?.posts?.pages?.hot?.comments) {
return; return;
@@ -131,18 +139,18 @@ const usePopularPosts = (communities: Array<Community | undefined>, communityAdd
console.error('Error in usePopularPosts:', err); console.error('Error in usePopularPosts:', err);
return []; return [];
} }
}, [nowSeconds, communities, communityAddresses]); }, [nowSeconds, communities, communityAddresses, cacheEntry]);
const hasPendingBoards = communityAddresses.some((_, index) => isBoardStillLoading(communities[index], loadingStartTimestamps[index], nowSeconds)); const hasPendingBoards = communityAddresses.some((_, index) => isBoardStillLoading(communities[index], loadingStartTimestamps[index], nowSeconds));
if (!committedRef.current.revealed && (candidates.length >= MAX_POSTS || (!hasPendingBoards && candidates.length > 0))) { if (!cacheEntry.revealed && (candidates.length >= MAX_POSTS || (!hasPendingBoards && candidates.length > 0))) {
committedRef.current.posts = candidates.slice(0, MAX_POSTS).map(({ post }) => post); cacheEntry.posts = candidates.slice(0, MAX_POSTS).map(({ post }) => post);
committedRef.current.revealed = true; cacheEntry.revealed = true;
} }
const isLoading = !committedRef.current.revealed; const isLoading = !cacheEntry.revealed;
return { popularPosts: committedRef.current.revealed ? committedRef.current.posts : [], isLoading, error: null as string | null }; return { popularPosts: cacheEntry.revealed ? cacheEntry.posts : [], isLoading, error: null as string | null };
}; };
export default usePopularPosts; export default usePopularPosts;