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;