diff --git a/src/views/catalog/__tests__/catalog.test.tsx b/src/views/catalog/__tests__/catalog.test.tsx index 32ab1308..61594153 100644 --- a/src/views/catalog/__tests__/catalog.test.tsx +++ b/src/views/catalog/__tests__/catalog.test.tsx @@ -3,7 +3,7 @@ import { createElement } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import Catalog, { type CatalogProps } from '../catalog'; +import Catalog, { getCatalogRenderFeed, type CatalogProps } from '../catalog'; (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; @@ -62,6 +62,7 @@ const testState = vi.hoisted(() => ({ setResetFunctionMock: vi.fn(), showOPComment: true, sortType: 'new' as 'active' | 'new', + virtuosoInitialScrollTops: [] as Array, windowWidth: 900, community: { error: undefined as Error | undefined, @@ -142,11 +143,13 @@ vi.mock('react-virtuoso', () => ({ components, data = [], endReached, + initialScrollTop, itemContent, }: { components?: { Footer?: React.ComponentType }; data?: Array; endReached?: ((index: number) => void) | undefined; + initialScrollTop?: number; itemContent: (index: number, item: TestComment[]) => React.ReactNode; }, ref: React.ForwardedRef<{ getState: (cb: (snapshot: { ranges: number[]; scrollTop: number }) => void) => void }>, @@ -154,6 +157,7 @@ vi.mock('react-virtuoso', () => ({ React.useImperativeHandle(ref, () => ({ getState: (cb) => cb({ ranges: [0], scrollTop: 24 }), })); + testState.virtuosoInitialScrollTops.push(initialScrollTop); return createElement( 'div', @@ -323,6 +327,7 @@ describe('Catalog', () => { testState.searchText = ''; testState.showOPComment = true; testState.sortType = 'new'; + testState.virtuosoInitialScrollTops = []; testState.windowWidth = 900; testState.community = { error: undefined, @@ -385,6 +390,13 @@ describe('Catalog', () => { expect(Array.from(container.querySelectorAll('[data-testid="catalog-row"]')).map((element) => element.textContent)).toEqual(['row:board-post-1,board-post-2']); }); + it('prefers the immediate feed until deferred catalog rows exist', () => { + const immediateFeed = [{ cid: 'all-post', title: 'one', communityAddress: 'music-posting.eth' }]; + + expect(getCatalogRenderFeed(immediateFeed, [])).toBe(immediateFeed); + expect(getCatalogRenderFeed(immediateFeed, immediateFeed)).toBe(immediateFeed); + }); + it('canonicalizes multiboard catalog paths and keeps load-more wired for infinite scrolling', async () => { testState.feed = [{ cid: 'all-post', title: 'one', communityAddress: 'music-posting.eth' }]; testState.hasMore = true; @@ -428,6 +440,30 @@ describe('Catalog', () => { root = createRoot(container); }); + it('restores multiboard catalog state after remounting', async () => { + testState.feed = [{ cid: 'all-post', title: 'one', communityAddress: 'music-posting.eth' }]; + const catalogProps = { feedCacheKey: 'restore-check', viewType: 'all' as const }; + + await renderCatalog({ + catalogProps, + initialEntry: '/all/catalog', + routePath: '/all/*', + }); + + expect(testState.virtuosoInitialScrollTops.at(-1)).toBeUndefined(); + + act(() => root.unmount()); + root = createRoot(container); + + await renderCatalog({ + catalogProps, + initialEntry: '/all/catalog', + routePath: '/all/*', + }); + + expect(testState.virtuosoInitialScrollTops.at(-1)).toBe(24); + }); + it('shows the empty subscriptions state when there are no subscribed boards to browse', async () => { testState.account = { subscriptions: [] }; diff --git a/src/views/catalog/catalog.tsx b/src/views/catalog/catalog.tsx index 0f1041d3..d7d6d50a 100644 --- a/src/views/catalog/catalog.tsx +++ b/src/views/catalog/catalog.tsx @@ -1,4 +1,4 @@ -import { useDeferredValue, useEffect, useMemo, useRef, useCallback } from 'react'; +import { useDeferredValue, useEffect, useMemo, useRef, useCallback, useLayoutEffect } from 'react'; import { useLocation, useNavigate, useNavigationType, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Comment, useAccount, useCommunity, useFeed, useAccountComments } from '@bitsocialnet/bitsocial-react-hooks'; @@ -37,6 +37,8 @@ const lastVirtuosoStates: { [key: string]: StateSnapshot } = {}; const RECENT_ACCOUNT_COMMENT_WINDOW_SECONDS = 60 * 60; // Keep the hook on its indexed fast path when this view should not inject local posts. const EMPTY_ACCOUNT_COMMENT_LOOKUP = { commentIndices: [-1] }; +export const getCatalogRenderFeed = (processedFeed: readonly T[], deferredProcessedFeed: readonly T[]): readonly T[] => + deferredProcessedFeed.length === 0 && processedFeed.length > 0 ? processedFeed : deferredProcessedFeed; interface CatalogFooterProps { communityAddresses: string[]; @@ -464,6 +466,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, }, [sortedFeed, filterItems]); const deferredProcessedFeed = useDeferredValue(processedFeed); + const catalogRenderFeed = getCatalogRenderFeed(processedFeed, deferredProcessedFeed); const matchedFilterColors = useMemo(() => { const nextMatchedFilterColors = new Map(); @@ -473,7 +476,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, return nextMatchedFilterColors; } - for (const comment of deferredProcessedFeed) { + for (const comment of catalogRenderFeed) { const cid = comment?.cid; if (!cid) { continue; @@ -486,7 +489,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, } return nextMatchedFilterColors; - }, [deferredProcessedFeed, filterItems]); + }, [catalogRenderFeed, filterItems]); const rowCacheRef = useRef(new Map()); const rows = useMemo(() => { @@ -498,8 +501,8 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, const effectiveColumnCount = Math.max(columnCount, 1); const nextRows: Comment[][] = []; const nextRowCache = new Map(); - for (let i = 0; i < deferredProcessedFeed.length; i += effectiveColumnCount) { - const nextRow = deferredProcessedFeed.slice(i, i + effectiveColumnCount); + for (let i = 0; i < catalogRenderFeed.length; i += effectiveColumnCount) { + const nextRow = catalogRenderFeed.slice(i, i + effectiveColumnCount); const rowKey = nextRow.map((post) => post?.cid || '').join('\u0000'); const cachedRow = rowCacheRef.current.get(rowKey); const stableRow = cachedRow && cachedRow.length === nextRow.length && cachedRow.every((post, index) => post === nextRow[index]) ? cachedRow : nextRow; @@ -508,7 +511,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, } rowCacheRef.current = nextRowCache; return nextRows; - }, [columnCount, deferredProcessedFeed, isFeedLoaded]); + }, [catalogRenderFeed, columnCount, isFeedLoaded]); const catalogMetrics = useMemo(() => readReplyTypographyMetrics(), [themeKey, windowWidth]); const rowHeightEstimates = useMemo( @@ -545,7 +548,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, } }, [isVisible, navigationType]); - useEffect(() => { + useLayoutEffect(() => { if (!isVisible || !shouldVirtualizeCatalog) return; const currentKey = virtuosoStateKey; @@ -592,7 +595,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,

- {processedFeed?.length !== 0 ? ( + {catalogRenderFeed.length !== 0 ? ( <> {shouldVirtualizeCatalog ? (