From 508fffe5d6077ed2dd9f5d66fdfbd38a45606254 Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Sun, 19 Apr 2026 14:24:35 +0700 Subject: [PATCH] fix(home): preserve popular threads during navigation --- .../__tests__/use-popular-posts.test.tsx | 36 ++++++++++- src/hooks/use-popular-posts.ts | 64 +++++++++++-------- 2 files changed, 69 insertions(+), 31 deletions(-) diff --git a/src/hooks/__tests__/use-popular-posts.test.tsx b/src/hooks/__tests__/use-popular-posts.test.tsx index 97b03538..e43966a2 100644 --- a/src/hooks/__tests__/use-popular-posts.test.tsx +++ b/src/hooks/__tests__/use-popular-posts.test.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { createElement } from 'react'; import { createRoot, type Root } from 'react-dom/client'; 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; const act = (React as { act?: (cb: () => void | Promise) => void | Promise }).act as (cb: () => void | Promise) => void | Promise; @@ -90,6 +90,7 @@ describe('usePopularPosts', () => { testState.currentTime = 1_704_067_200; testState.loadingTimestamps = []; testState.requestedAddresses = undefined; + clearPopularPostsCacheForTest(); latestValue = { error: null, @@ -185,7 +186,7 @@ describe('usePopularPosts', () => { 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 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)); @@ -203,8 +204,37 @@ describe('usePopularPosts', () => { await renderHook(addresses, communities); 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(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(); }); diff --git a/src/hooks/use-popular-posts.ts b/src/hooks/use-popular-posts.ts index d62b74ef..00ebcc23 100644 --- a/src/hooks/use-popular-posts.ts +++ b/src/hooks/use-popular-posts.ts @@ -1,4 +1,4 @@ -import { useMemo, useRef } from 'react'; +import { useMemo } from 'react'; import { Comment, type Community } from '@bitsocialnet/bitsocial-react-hooks'; import { getCommentMediaInfo, getHasThumbnail } from '../lib/utils/media-utils'; import useCommunitiesLoadingStartTimestamps from '../stores/use-communities-loading-start-timestamps-store'; @@ -15,11 +15,14 @@ type PopularPostCandidate = { post: Comment; }; -type CommittedPopularPosts = { +type PopularPostsCacheEntry = { + randomizedBoardAddresses: string[]; posts: Comment[]; revealed: boolean; }; +const popularPostsCacheByInputKey = new Map(); + /** * Time-decayed popularity: replyCount divided by age of latest * activity so a stale post with many old replies loses to a newer @@ -55,38 +58,43 @@ function shuffleBoardAddresses(boardAddresses: string[]): string[] { 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 - * 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 board filter, so threads never disappear during background loads. */ const usePopularPosts = (communities: Array, communityAddresses: string[]) => { const inputKey = [...communityAddresses].sort().join(','); - const committedRef = useRef({ - posts: [], - revealed: false, - }); - const prevInputKeyRef = useRef(''); - const randomizedBoardAddressesRef = useRef([]); + const cacheEntry = getPopularPostsCacheEntry(inputKey, communityAddresses); - // Reset committed and reshuffle when the requested board set changes (e.g. NSFW filter toggle). - 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 currentTime = useCurrentTime(cacheEntry.revealed ? 300 : 5); const nowSeconds = Math.floor(currentTime); const loadingStartTimestamps = useCommunitiesLoadingStartTimestamps(communityAddresses); const candidates = useMemo(() => { - if (committedRef.current.revealed || committedRef.current.posts.length >= MAX_POSTS) { + if (cacheEntry.revealed || cacheEntry.posts.length >= MAX_POSTS) { return []; } @@ -95,7 +103,7 @@ const usePopularPosts = (communities: Array, communityAdd const allPosts: PopularPostCandidate[] = []; const communitiesByAddress = new Map(communityAddresses.map((boardAddress, index) => [boardAddress, communities[index]])); - randomizedBoardAddressesRef.current.forEach((boardAddress) => { + cacheEntry.randomizedBoardAddresses.forEach((boardAddress) => { const community = communitiesByAddress.get(boardAddress); if (!boardAddress || !community?.posts?.pages?.hot?.comments) { return; @@ -131,18 +139,18 @@ const usePopularPosts = (communities: Array, communityAdd console.error('Error in usePopularPosts:', err); return []; } - }, [nowSeconds, communities, communityAddresses]); + }, [nowSeconds, communities, communityAddresses, cacheEntry]); const hasPendingBoards = communityAddresses.some((_, index) => isBoardStillLoading(communities[index], loadingStartTimestamps[index], nowSeconds)); - if (!committedRef.current.revealed && (candidates.length >= MAX_POSTS || (!hasPendingBoards && candidates.length > 0))) { - committedRef.current.posts = candidates.slice(0, MAX_POSTS).map(({ post }) => post); - committedRef.current.revealed = true; + if (!cacheEntry.revealed && (candidates.length >= MAX_POSTS || (!hasPendingBoards && candidates.length > 0))) { + cacheEntry.posts = candidates.slice(0, MAX_POSTS).map(({ post }) => post); + 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;