diff --git a/src/__tests__/app.test.tsx b/src/__tests__/app.test.tsx index 9849aa25..21437ca0 100644 --- a/src/__tests__/app.test.tsx +++ b/src/__tests__/app.test.tsx @@ -349,6 +349,7 @@ describe('App', () => { await renderApp('/mod/queue'); expect(container.querySelector('[data-testid="mod-queue-view"]')).toBeTruthy(); + expect(container.querySelector('[data-testid="feed-cache-container"]')).toBeNull(); act(() => root.unmount()); root = createRoot(container); diff --git a/src/app.tsx b/src/app.tsx index 25b0f00e..8cffc979 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -170,7 +170,7 @@ const BoardLayout = () => { )} - + {!isOnModQueueRoute && } {shouldRenderOutlet && } ); diff --git a/src/hooks/__tests__/selector-hooks.test.tsx b/src/hooks/__tests__/selector-hooks.test.tsx index db03a31d..df99679c 100644 --- a/src/hooks/__tests__/selector-hooks.test.tsx +++ b/src/hooks/__tests__/selector-hooks.test.tsx @@ -23,11 +23,45 @@ const testState = vi.hoisted(() => ({ communitySnapshot: undefined as unknown, })); +const accountsStoreSelectorCache = vi.hoisted(() => ({ + hasValue: false, + value: undefined as unknown, +})); + vi.mock('@bitsocial/bitsocial-react-hooks', () => ({ useAccount: () => testState.account, useAccountCommunities: () => ({ accountCommunities: testState.accountCommunities }), })); +vi.mock('@bitsocial/bitsocial-react-hooks/dist/stores/accounts/index.js', () => ({ + default: ( + selector: (state: { activeAccountId?: string; accounts: Record }) => unknown, + equalityFn?: (previous: unknown, next: unknown) => boolean, + ) => { + const nextValue = selector({ + activeAccountId: 'active', + accounts: { + active: { + communities: testState.accountCommunities, + }, + }, + }); + + if (accountsStoreSelectorCache.hasValue && equalityFn?.(accountsStoreSelectorCache.value, nextValue)) { + return accountsStoreSelectorCache.value; + } + + accountsStoreSelectorCache.hasValue = true; + accountsStoreSelectorCache.value = nextValue; + return nextValue; + }, +})); + +vi.mock('@bitsocial/bitsocial-react-hooks/dist/lib/community-address.js', () => ({ + getEquivalentCommunityAddressGroupKey: (address: string) => (address.endsWith('.eth') ? address.slice(0, -4) + '.bso' : address), + pickPreferredEquivalentCommunityAddress: (addresses: string[]) => addresses.find((address) => address.endsWith('.bso')) || addresses[0], +})); + vi.mock('@bitsocial/bitsocial-react-hooks/dist/lib/utils', () => ({ flattenCommentsPages: () => testState.flattenedReplies, })); @@ -60,6 +94,14 @@ const renderHookValue = (useValue: () => unknown) => { return latestValue; }; +const rerenderHookValue = (useValue: () => unknown) => { + act(() => { + root.render(createElement(HookHarness, { useValue })); + }); + + return latestValue; +}; + describe('selector hooks', () => { beforeEach(() => { latestValue = undefined; @@ -72,6 +114,8 @@ describe('selector hooks', () => { testState.directoryLookup = {}; testState.flattenedReplies = []; testState.communitySnapshot = undefined; + accountsStoreSelectorCache.hasValue = false; + accountsStoreSelectorCache.value = undefined; useAllFeedFilterStore.getState().setFilter('all'); container = document.createElement('div'); @@ -97,6 +141,31 @@ describe('selector hooks', () => { ]); }); + it('keeps account board address identity stable when cached community objects change', () => { + testState.accountCommunities = { + 'music.eth': { address: 'music.eth', state: 'updating' }, + 'tech.eth': { address: 'tech.eth', state: 'updating' }, + }; + + const initialAddresses = rerenderHookValue(() => useAccountCommunityAddresses()); + + testState.accountCommunities = { + 'music.eth': { address: 'music.eth', state: 'succeeded' }, + 'tech.eth': { address: 'tech.eth', state: 'updating' }, + }; + + expect(rerenderHookValue(() => useAccountCommunityAddresses())).toBe(initialAddresses); + + testState.accountCommunities = { + ...testState.accountCommunities, + 'biz.eth': { address: 'biz.eth', state: 'updating' }, + }; + + const addressesWithNewBoard = rerenderHookValue(() => useAccountCommunityAddresses()); + expect(addressesWithNewBoard).not.toBe(initialAddresses); + expect(addressesWithNewBoard).toEqual(['biz.eth', 'music.eth', 'tech.eth']); + }); + it('computes moderator privileges and whether the current account authored the comment', () => { testState.account = { author: { address: '0xme' } }; testState.communitySnapshot = { diff --git a/src/hooks/use-account-community-addresses.ts b/src/hooks/use-account-community-addresses.ts index a9467e87..bb705694 100644 --- a/src/hooks/use-account-community-addresses.ts +++ b/src/hooks/use-account-community-addresses.ts @@ -1,8 +1,45 @@ -import { useMemo } from 'react'; -import { useAccountCommunities } from '@bitsocial/bitsocial-react-hooks'; +import useAccountsStore from '@bitsocial/bitsocial-react-hooks/dist/stores/accounts/index.js'; +import { getEquivalentCommunityAddressGroupKey, pickPreferredEquivalentCommunityAddress } from '@bitsocial/bitsocial-react-hooks/dist/lib/community-address.js'; -export const useAccountCommunityAddresses = (): string[] => { - const { accountCommunities } = useAccountCommunities({ onlyIfCached: true }); - - return useMemo(() => Object.keys(accountCommunities), [accountCommunities]); +type AccountWithCommunities = { + communities?: Record; }; + +type AccountsStoreState = { + activeAccountId?: string; + accounts: Record; +}; + +const EMPTY_ACCOUNT_COMMUNITY_ADDRESSES: string[] = []; + +const areStringArraysEqual = (previous: string[], next: string[]) => { + if (previous === next) { + return true; + } + if (previous.length !== next.length) { + return false; + } + return previous.every((value, index) => value === next[index]); +}; + +const getAccountCommunityAddresses = (state: AccountsStoreState): string[] => { + const accountCommunities = state.activeAccountId ? state.accounts[state.activeAccountId]?.communities : undefined; + if (!accountCommunities) { + return EMPTY_ACCOUNT_COMMUNITY_ADDRESSES; + } + + const groupedAddresses = new Map(); + for (const communityAddress of Object.keys(accountCommunities)) { + const groupKey = getEquivalentCommunityAddressGroupKey(communityAddress); + const addresses = groupedAddresses.get(groupKey); + if (addresses) { + addresses.push(communityAddress); + } else { + groupedAddresses.set(groupKey, [communityAddress]); + } + } + + return [...groupedAddresses.values()].map((addresses) => pickPreferredEquivalentCommunityAddress(addresses)).sort(); +}; + +export const useAccountCommunityAddresses = (): string[] => useAccountsStore(getAccountCommunityAddresses, areStringArraysEqual); diff --git a/src/stores/__tests__/interaction-stores.test.ts b/src/stores/__tests__/interaction-stores.test.ts index edcd9ad0..1bdd1640 100644 --- a/src/stores/__tests__/interaction-stores.test.ts +++ b/src/stores/__tests__/interaction-stores.test.ts @@ -38,7 +38,7 @@ describe('interaction stores', () => { useCreateBoardModalStore.getState().closeCreateBoardModal(); useDirectoryModalStore.getState().closeDirectoryModal(); useDisclaimerModalStore.getState().closeDisclaimerModal(); - useFeedResetStore.setState({ reset: null }); + useFeedResetStore.setState({ currentResetFunction: null, reset: null }); usePostNumberStore.setState({ numberToCid: {}, cidToNumber: {} }); useSelectedTextStore.getState().resetSelectedText(); useSortingStore.getState().setSortType('active'); @@ -80,9 +80,17 @@ describe('interaction stores', () => { const resetMock = vi.fn(); useFeedResetStore.getState().setResetFunction(resetMock); + const firstResetInvoker = useFeedResetStore.getState().reset; useFeedResetStore.getState().reset?.(); expect(resetMock).toHaveBeenCalledTimes(1); + const nextResetMock = vi.fn(); + useFeedResetStore.getState().setResetFunction(nextResetMock); + expect(useFeedResetStore.getState().reset).toBe(firstResetInvoker); + useFeedResetStore.getState().reset?.(); + expect(resetMock).toHaveBeenCalledTimes(1); + expect(nextResetMock).toHaveBeenCalledTimes(1); + expect(useSortingStore.getState().sortType).toBe('active'); useSortingStore.getState().setSortType('replyCount'); expect(useSortingStore.getState().sortType).toBe('replyCount'); diff --git a/src/stores/use-feed-reset-store.ts b/src/stores/use-feed-reset-store.ts index f6c6ddd9..15ac6df3 100644 --- a/src/stores/use-feed-reset-store.ts +++ b/src/stores/use-feed-reset-store.ts @@ -2,12 +2,26 @@ import { create } from 'zustand'; interface FeedResetState { reset: (() => void) | null; + currentResetFunction: (() => void) | null; setResetFunction: (resetFunction: () => void) => void; } +const invokeCurrentResetFunction = () => { + useFeedResetStore.getState().currentResetFunction?.(); +}; + const useFeedResetStore = create((set) => ({ reset: null, - setResetFunction: (resetFunction) => set({ reset: resetFunction }), + currentResetFunction: null, + setResetFunction: (resetFunction) => + set((state) => + state.currentResetFunction === resetFunction && state.reset + ? state + : { + currentResetFunction: resetFunction, + reset: state.reset ?? invokeCurrentResetFunction, + }, + ), })); export default useFeedResetStore;