Refetch channels on reconnect and fix up count-change refresh guard

This commit is contained in:
Jack Kingsman
2026-03-09 16:44:39 -07:00
parent b157ee14e4
commit 9421c10e8f
4 changed files with 98 additions and 7 deletions

View File

@@ -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);

View File

@@ -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]);

View File

@@ -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(<App />);
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(<App />);

View File

@@ -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({