From 9421c10e8f1a9763d2009ea75c33b29623f3debe Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 9 Mar 2026 16:44:39 -0700 Subject: [PATCH] Refetch channels on reconnect and fix up count-change refresh guard --- frontend/src/App.tsx | 1 + frontend/src/hooks/useUnreadCounts.ts | 14 +++--- frontend/src/test/appFavorites.test.tsx | 34 +++++++++++++- frontend/src/test/useUnreadCounts.test.ts | 56 +++++++++++++++++++++++ 4 files changed, 98 insertions(+), 7 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d50cdae..b34a350 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -300,6 +300,7 @@ export function App() { // Silently recover any data missed during the disconnect window triggerReconcile(); refreshUnreads(); + api.getChannels().then(setChannels).catch(console.error); fetchAllContacts() .then((data) => setContacts(data)) .catch(console.error); diff --git a/frontend/src/hooks/useUnreadCounts.ts b/frontend/src/hooks/useUnreadCounts.ts index 5564cd7..9a043c7 100644 --- a/frontend/src/hooks/useUnreadCounts.ts +++ b/frontend/src/hooks/useUnreadCounts.ts @@ -88,10 +88,12 @@ export function useUnreadCounts( // On mount, consume the prefetched promise (started in index.html before // React loaded) or fall back to a fresh fetch. // Re-fetch when channel/contact count changes mid-session (new sync, cracker - // channel created, etc.) but skip the initial 0→N load to avoid double calls. + // channel created, etc.). Skip only the very first run of this effect; after + // that, any count change should trigger a refresh, even if the other + // collection is still empty. const channelsLen = channels.length; const contactsLen = contacts.length; - const prevLens = useRef({ channels: 0, contacts: 0 }); + const hasObservedCountsRef = useRef(false); useEffect(() => { takePrefetchOrFetch('unreads', api.getUnreads) .then(applyUnreads) @@ -100,10 +102,10 @@ export function useUnreadCounts( }); }, [applyUnreads]); useEffect(() => { - const prev = prevLens.current; - prevLens.current = { channels: channelsLen, contacts: contactsLen }; - // Skip the initial load (0→N); only refetch on mid-session count changes - if (prev.channels === 0 || prev.contacts === 0) return; + if (!hasObservedCountsRef.current) { + hasObservedCountsRef.current = true; + return; + } fetchUnreads(); }, [channelsLen, contactsLen, fetchUnreads]); diff --git a/frontend/src/test/appFavorites.test.tsx b/frontend/src/test/appFavorites.test.tsx index 7037da5..313e05c 100644 --- a/frontend/src/test/appFavorites.test.tsx +++ b/frontend/src/test/appFavorites.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ @@ -36,9 +36,11 @@ const mocks = vi.hoisted(() => ({ fetchOlderMessages: vi.fn(async () => {}), addMessageIfNew: vi.fn(), updateMessageAck: vi.fn(), + triggerReconcile: vi.fn(), incrementUnread: vi.fn(), markAllRead: vi.fn(), trackNewMessage: vi.fn(), + refreshUnreads: vi.fn(async () => {}), }, })); @@ -59,11 +61,17 @@ vi.mock('../hooks', async (importOriginal) => { messagesLoading: false, loadingOlder: false, hasOlderMessages: false, + hasNewerMessages: false, + loadingNewer: false, + hasNewerMessagesRef: { current: false }, setMessages: mocks.hookFns.setMessages, fetchMessages: mocks.hookFns.fetchMessages, fetchOlderMessages: mocks.hookFns.fetchOlderMessages, + fetchNewerMessages: vi.fn(async () => {}), + jumpToBottom: vi.fn(), addMessageIfNew: mocks.hookFns.addMessageIfNew, updateMessageAck: mocks.hookFns.updateMessageAck, + triggerReconcile: mocks.hookFns.triggerReconcile, }), useUnreadCounts: () => ({ unreadCounts: {}, @@ -72,6 +80,7 @@ vi.mock('../hooks', async (importOriginal) => { incrementUnread: mocks.hookFns.incrementUnread, markAllRead: mocks.hookFns.markAllRead, trackNewMessage: mocks.hookFns.trackNewMessage, + refreshUnreads: mocks.hookFns.refreshUnreads, }), getMessageContentKey: () => 'content-key', }; @@ -165,6 +174,7 @@ vi.mock('../utils/urlHash', () => ({ })); import { App } from '../App'; +import { useWebSocket } from '../useWebSocket'; const baseConfig = { public_key: 'aa'.repeat(32), @@ -264,6 +274,28 @@ describe('App favorite toggle flow', () => { }); }); + it('re-fetches channels after WebSocket reconnect', async () => { + render(); + + await waitFor(() => { + expect(mocks.api.getChannels).toHaveBeenCalledTimes(1); + }); + + const wsHandlers = vi.mocked(useWebSocket).mock.calls[0]?.[0]; + expect(wsHandlers?.onReconnect).toBeTypeOf('function'); + + await act(async () => { + wsHandlers?.onReconnect?.(); + await Promise.resolve(); + }); + + await waitFor(() => { + expect(mocks.api.getChannels).toHaveBeenCalledTimes(2); + }); + expect(mocks.hookFns.triggerReconcile).toHaveBeenCalledTimes(1); + expect(mocks.hookFns.refreshUnreads).toHaveBeenCalledTimes(1); + }); + it('toggles settings page mode and syncs selected section into SettingsModal', async () => { render(); diff --git a/frontend/src/test/useUnreadCounts.test.ts b/frontend/src/test/useUnreadCounts.test.ts index 47bc492..c936608 100644 --- a/frontend/src/test/useUnreadCounts.test.ts +++ b/frontend/src/test/useUnreadCounts.test.ts @@ -228,6 +228,62 @@ describe('useUnreadCounts', () => { expect(result.current.unreadCounts[`channel-${CHANNEL_KEY}`]).toBeUndefined(); }); + it('re-fetches when channels change while contacts remain empty', async () => { + const mocks = await getMockedApi(); + const initialChannels = [makeChannel(CHANNEL_KEY, 'Test')]; + const addedChannelKey = '11223344556677889900AABBCCDDEEFF'; + + const { rerender } = renderWith({ channels: initialChannels, contacts: [] }); + + await act(async () => { + await vi.waitFor(() => expect(mocks.getUnreads).toHaveBeenCalledTimes(1)); + }); + + mocks.getUnreads.mockResolvedValueOnce({ + counts: { [`channel-${addedChannelKey}`]: 2 }, + mentions: {}, + last_message_times: {}, + }); + + rerender({ + channels: [...initialChannels, makeChannel(addedChannelKey, 'Added')], + contacts: [], + activeConversation: null, + }); + + await act(async () => { + await vi.waitFor(() => expect(mocks.getUnreads).toHaveBeenCalledTimes(2)); + }); + }); + + it('re-fetches when contacts change while channels remain empty', async () => { + const mocks = await getMockedApi(); + const initialContact = makeContact(CONTACT_KEY); + const addedContactKey = 'ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100'; + + const { rerender } = renderWith({ channels: [], contacts: [initialContact] }); + + await act(async () => { + await vi.waitFor(() => expect(mocks.getUnreads).toHaveBeenCalledTimes(1)); + }); + + mocks.getUnreads.mockResolvedValueOnce({ + counts: { [`contact-${addedContactKey}`]: 1 }, + mentions: {}, + last_message_times: {}, + }); + + rerender({ + channels: [], + contacts: [initialContact, makeContact(addedContactKey)], + activeConversation: null, + }); + + await act(async () => { + await vi.waitFor(() => expect(mocks.getUnreads).toHaveBeenCalledTimes(2)); + }); + }); + it('does not filter when no active conversation', async () => { const mocks = await getMockedApi(); mocks.getUnreads.mockResolvedValue({