From 5e5ab7db4d1c276a6489c6c4838c20b6221f21cf Mon Sep 17 00:00:00 2001 From: Ryan Gregg Date: Sat, 25 Jul 2026 00:20:48 -0700 Subject: [PATCH 1/2] Keep overheard packet traffic out of the chat render path Typing in a conversation got progressively slower as its history grew. The cause was not the input: every packet the node overhears was stored in App state, so each one re-rendered the whole tree, including the un-memoized MessageList. That cost scales with history length, and on a busy mesh it saturated the main thread so keystrokes queued behind it. Measured in Chromium with the real components, cost per overheard packet: history before after 50 7.3ms ~0ms 500 48.5ms ~0ms 1000 97.7ms ~0ms 2000 228.4ms ~0ms The packet stream now lives in a small external store that views subscribe to individually, so only the map, visualizer, raw feed, and cracker re-render when a packet arrives, so the chat view is no longer along for the ride. This also retires useRawPacketStatsSession, whose session state was App state for the same reason. --- frontend/src/App.tsx | 16 +- frontend/src/components/ConversationPane.tsx | 23 +- frontend/src/components/CrackerPanel.tsx | 4 +- frontend/src/components/MapView.tsx | 6 +- frontend/src/components/RawPacketFeedView.tsx | 12 +- frontend/src/components/VisualizerView.tsx | 5 +- frontend/src/hooks/index.ts | 1 - .../src/hooks/useRawPacketStatsSession.ts | 52 ---- frontend/src/hooks/useRealtimeAppState.ts | 13 +- frontend/src/stores/rawPacketStore.ts | 144 +++++++++++ frontend/src/test/crackerPanel.test.tsx | 2 +- frontend/src/test/rawPacketFeedView.test.tsx | 39 +-- frontend/src/test/rawPacketStore.test.tsx | 226 ++++++++++++++++++ frontend/src/test/useRealtimeAppState.test.ts | 47 ++-- frontend/src/test/visualizerView.test.tsx | 14 +- 15 files changed, 435 insertions(+), 169 deletions(-) delete mode 100644 frontend/src/hooks/useRawPacketStatsSession.ts create mode 100644 frontend/src/stores/rawPacketStore.ts create mode 100644 frontend/src/test/rawPacketStore.test.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3289260..71e62a7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,7 +16,6 @@ import { useBrowserNotifications, useFaviconBadge, useUnreadTitle, - useRawPacketStatsSession, } from './hooks'; import { toast } from './components/ui/sonner'; import { AppShell } from './components/AppShell'; @@ -27,13 +26,7 @@ import { RichPayloadProvider } from './contexts/RichPayloadContext'; import { usePush } from './contexts/PushSubscriptionContext'; import { messageContainsMention } from './utils/messageParser'; import { getStateKey } from './utils/conversationState'; -import type { - BulkCreateHashtagChannelsResult, - Channel, - Conversation, - Message, - RawPacket, -} from './types'; +import type { BulkCreateHashtagChannelsResult, Channel, Conversation, Message } from './types'; import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from './types'; import { shouldAutoFocusInput } from './utils/autoFocusInput'; @@ -92,7 +85,6 @@ export function App() { }, []); const messageInputRef = useRef(null); - const [rawPackets, setRawPackets] = useState([]); const [channelUnreadMarker, setChannelUnreadMarker] = useState(null); const [newMessagePrefillRequest, setNewMessagePrefillRequest] = useState(null); @@ -109,7 +101,6 @@ export function App() { notifyIncomingMessage, } = useBrowserNotifications(); const pushSubscription = usePush(); - const { rawPacketStatsSession, recordRawPacketObservation } = useRawPacketStatsSession(); const { showNewMessage, showSettings, @@ -436,7 +427,6 @@ export function App() { prevHealthRef, setHealth, fetchConfig, - setRawPackets, reconcileOnReconnect, refreshUnreads, setChannels, @@ -457,7 +447,6 @@ export function App() { removeConversationMessages, receiveMessageAck, notifyIncomingMessage, - recordRawPacketObservation, }); const handleVisibilityPolicyChanged = useCallback(() => { clearConversationMessages(); @@ -601,8 +590,6 @@ export function App() { activeConversation, contacts, channels, - rawPackets, - rawPacketStatsSession, config, health, messages: sortedMessages, @@ -734,7 +721,6 @@ export function App() { onToggleTrackedTelemetryContact: handleToggleTrackedTelemetryContact, }; const crackerProps = { - packets: rawPackets, channels, onChannelCreate: handleCreateCrackedChannel, }; diff --git a/frontend/src/components/ConversationPane.tsx b/frontend/src/components/ConversationPane.tsx index 7e12c70..3b8ec19 100644 --- a/frontend/src/components/ConversationPane.tsx +++ b/frontend/src/components/ConversationPane.tsx @@ -13,12 +13,10 @@ import type { HealthStatus, Message, PathDiscoveryResponse, - RawPacket, RadioConfig, RadioTraceHopRequest, RadioTraceResponse, } from '../types'; -import type { RawPacketStatsSessionState } from '../utils/rawPacketStats'; import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types'; import { getContactDisplayName, @@ -38,8 +36,6 @@ interface ConversationPaneProps { activeConversation: Conversation | null; contacts: Contact[]; channels: Channel[]; - rawPackets: RawPacket[]; - rawPacketStatsSession: RawPacketStatsSessionState; config: RadioConfig | null; health: HealthStatus | null; notificationsSupported: boolean; @@ -125,8 +121,6 @@ export function ConversationPane({ activeConversation, contacts, channels, - rawPackets, - rawPacketStatsSession, config, health, notificationsSupported, @@ -217,7 +211,6 @@ export function ConversationPane({ }> - + ); } if (activeConversation.type === 'raw') { - return ( - - ); + return ; } if (activeConversation.type === 'search') { diff --git a/frontend/src/components/CrackerPanel.tsx b/frontend/src/components/CrackerPanel.tsx index d2caec4..42bce61 100644 --- a/frontend/src/components/CrackerPanel.tsx +++ b/frontend/src/components/CrackerPanel.tsx @@ -6,6 +6,7 @@ import { api } from '../api'; import { toast } from './ui/sonner'; import { cn } from '@/lib/utils'; import { extractPacketPayloadHex } from '../utils/pathUtils'; +import { useRawPackets } from '../stores/rawPacketStore'; interface CrackedChannel { channelName: string; @@ -23,7 +24,6 @@ interface QueueItem { } export interface CrackerPanelProps { - packets: RawPacket[]; channels: Channel[]; onChannelCreate: (name: string, key: string) => Promise; onRunningChange?: (running: boolean) => void; @@ -31,12 +31,12 @@ export interface CrackerPanelProps { } export function CrackerPanel({ - packets, channels, onChannelCreate, onRunningChange, visible = false, }: CrackerPanelProps) { + const packets = useRawPackets(); const [isRunning, setIsRunning] = useState(false); const [maxLength, setMaxLength] = useState(6); const [maxLengthInput, setMaxLengthInput] = useState('6'); diff --git a/frontend/src/components/MapView.tsx b/frontend/src/components/MapView.tsx index bc2e462..0e21bc9 100644 --- a/frontend/src/components/MapView.tsx +++ b/frontend/src/components/MapView.tsx @@ -12,7 +12,7 @@ import { import type { LatLngBoundsExpression, CircleMarker as LeafletCircleMarker } from 'leaflet'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; -import type { Contact, RadioConfig, RawPacket } from '../types'; +import type { Contact, RadioConfig } from '../types'; import { formatTime } from '../utils/messageParser'; import { isValidLocation } from '../utils/pathUtils'; import { CONTACT_TYPE_REPEATER } from '../types'; @@ -23,13 +23,13 @@ import { dedupeConsecutive, } from '../utils/visualizerUtils'; import { getRawPacketObservationKey } from '../utils/rawPacketIdentity'; +import { useRawPackets } from '../stores/rawPacketStore'; import { cn } from '@/lib/utils'; interface MapViewProps { contacts: Contact[]; /** Public key of contact to focus on and open popup */ focusedKey?: string | null; - rawPackets?: RawPacket[]; config?: RadioConfig | null; blockedKeys?: string[]; blockedNames?: string[]; @@ -541,12 +541,12 @@ function ParticleOverlay({ particles }: { particles: MapParticle[] }) { export function MapView({ contacts, focusedKey, - rawPackets, config, blockedKeys, blockedNames, onSelectContact, }: MapViewProps) { + const rawPackets = useRawPackets(); const [sinceId, setSinceId] = useState(getSavedSinceId); const [customSince, setCustomSince] = useState(''); const [nowSec, setNowSec] = useState(() => Date.now() / 1000); diff --git a/frontend/src/components/RawPacketFeedView.tsx b/frontend/src/components/RawPacketFeedView.tsx index a6af44a..0127c27 100644 --- a/frontend/src/components/RawPacketFeedView.tsx +++ b/frontend/src/components/RawPacketFeedView.tsx @@ -28,6 +28,7 @@ import { type RawPacketStatsWindow, } from '../utils/rawPacketStats'; import { createDecoderOptions } from '../utils/rawPacketInspector'; +import { useRawPacketStatsSession, useRawPackets } from '../stores/rawPacketStore'; import { getContactDisplayName } from '../utils/pubkey'; import { cn } from '@/lib/utils'; @@ -197,8 +198,6 @@ function FeedFilterControls({ } interface RawPacketFeedViewProps { - packets: RawPacket[]; - rawPacketStatsSession: RawPacketStatsSessionState; contacts: Contact[]; channels: Channel[]; } @@ -588,12 +587,9 @@ function TimelineChart({ ); } -export function RawPacketFeedView({ - packets, - rawPacketStatsSession, - contacts, - channels, -}: RawPacketFeedViewProps) { +export function RawPacketFeedView({ contacts, channels }: RawPacketFeedViewProps) { + const packets = useRawPackets(); + const rawPacketStatsSession = useRawPacketStatsSession(); const [statsOpen, setStatsOpen] = useState(() => typeof window !== 'undefined' && typeof window.matchMedia === 'function' ? window.matchMedia('(min-width: 768px)').matches diff --git a/frontend/src/components/VisualizerView.tsx b/frontend/src/components/VisualizerView.tsx index 8033ebb..7344bb6 100644 --- a/frontend/src/components/VisualizerView.tsx +++ b/frontend/src/components/VisualizerView.tsx @@ -7,15 +7,16 @@ import { RawPacketInspectorDialog } from './RawPacketDetailModal'; import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs'; import { cn } from '@/lib/utils'; import { getVisualizerSettings, saveVisualizerSettings } from '../utils/visualizerSettings'; +import { useRawPackets } from '../stores/rawPacketStore'; interface VisualizerViewProps { - packets: RawPacket[]; contacts: Contact[]; channels: Channel[]; config: RadioConfig | null; } -export function VisualizerView({ packets, contacts, channels, config }: VisualizerViewProps) { +export function VisualizerView({ contacts, channels, config }: VisualizerViewProps) { + const packets = useRawPackets(); const [fullScreen, setFullScreen] = useState(() => getVisualizerSettings().hidePacketFeed); const [paneFullScreen, setPaneFullScreen] = useState(false); const [selectedPacket, setSelectedPacket] = useState(null); diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index a79a475..1143574 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -11,4 +11,3 @@ export { useConversationActions } from './useConversationActions'; export { useConversationNavigation } from './useConversationNavigation'; export { useBrowserNotifications } from './useBrowserNotifications'; export { useFaviconBadge, useUnreadTitle } from './useFaviconBadge'; -export { useRawPacketStatsSession } from './useRawPacketStatsSession'; diff --git a/frontend/src/hooks/useRawPacketStatsSession.ts b/frontend/src/hooks/useRawPacketStatsSession.ts deleted file mode 100644 index 24bfc6a..0000000 --- a/frontend/src/hooks/useRawPacketStatsSession.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { useCallback, useState } from 'react'; - -import type { RawPacket } from '../types'; -import { - MAX_RAW_PACKET_STATS_OBSERVATIONS, - summarizeRawPacketForStats, - type RawPacketStatsSessionState, -} from '../utils/rawPacketStats'; - -export function useRawPacketStatsSession() { - const [session, setSession] = useState(() => ({ - sessionStartedAt: Date.now(), - totalObservedPackets: 0, - trimmedObservationCount: 0, - observations: [], - })); - - const recordRawPacketObservation = useCallback((packet: RawPacket) => { - setSession((prev) => { - const observation = summarizeRawPacketForStats(packet); - if ( - prev.observations.some( - (candidate) => candidate.observationKey === observation.observationKey - ) - ) { - return prev; - } - - const observations = [...prev.observations, observation]; - if (observations.length <= MAX_RAW_PACKET_STATS_OBSERVATIONS) { - return { - ...prev, - totalObservedPackets: prev.totalObservedPackets + 1, - observations, - }; - } - - const overflow = observations.length - MAX_RAW_PACKET_STATS_OBSERVATIONS; - return { - ...prev, - totalObservedPackets: prev.totalObservedPackets + 1, - trimmedObservationCount: prev.trimmedObservationCount + overflow, - observations: observations.slice(overflow), - }; - }); - }, []); - - return { - rawPacketStatsSession: session, - recordRawPacketObservation, - }; -} diff --git a/frontend/src/hooks/useRealtimeAppState.ts b/frontend/src/hooks/useRealtimeAppState.ts index 5bbfae4..e37034e 100644 --- a/frontend/src/hooks/useRealtimeAppState.ts +++ b/frontend/src/hooks/useRealtimeAppState.ts @@ -11,7 +11,7 @@ import { toast } from '../components/ui/sonner'; import { getStateKey } from '../utils/conversationState'; import { mergeContactIntoList } from '../utils/contactMerge'; import { getContactDisplayName } from '../utils/pubkey'; -import { appendRawPacketUnique } from '../utils/rawPacketIdentity'; +import { clearRawPackets, recordRawPacket } from '../stores/rawPacketStore'; import { emitStatusDotPulse } from '../utils/statusDotPulse'; import type { Channel, @@ -27,7 +27,6 @@ interface UseRealtimeAppStateArgs { prevHealthRef: MutableRefObject; setHealth: Dispatch>; fetchConfig: () => void | Promise; - setRawPackets: Dispatch>; reconcileOnReconnect: () => void; refreshUnreads: () => Promise; setChannels: Dispatch>; @@ -58,7 +57,6 @@ interface UseRealtimeAppStateArgs { packetId?: number | null ) => void; notifyIncomingMessage?: (msg: Message) => void; - recordRawPacketObservation?: (packet: RawPacket) => void; maxRawPackets?: number; } @@ -87,7 +85,6 @@ export function useRealtimeAppState({ prevHealthRef, setHealth, fetchConfig, - setRawPackets, reconcileOnReconnect, refreshUnreads, setChannels, @@ -108,7 +105,6 @@ export function useRealtimeAppState({ removeConversationMessages, receiveMessageAck, notifyIncomingMessage, - recordRawPacketObservation, maxRawPackets = 500, }: UseRealtimeAppStateArgs): UseWebSocketOptions { const mergeChannelIntoList = useCallback( @@ -180,7 +176,7 @@ export function useRealtimeAppState({ }); }, onReconnect: () => { - setRawPackets([]); + clearRawPackets(); reconcileOnReconnect(); refreshUnreads(); api.getChannels().then(setChannels).catch(console.error); @@ -263,9 +259,8 @@ export function useRealtimeAppState({ } }, onRawPacket: (packet: RawPacket) => { - recordRawPacketObservation?.(packet); emitStatusDotPulse(packet.payload_type); - setRawPackets((prev) => appendRawPacketUnique(prev, packet, maxRawPackets)); + recordRawPacket(packet, maxRawPackets); }, onMessageAcked: ( messageId: number, @@ -291,7 +286,6 @@ export function useRealtimeAppState({ pendingDeleteFallbackRef, prevHealthRef, recordMessageEvent, - recordRawPacketObservation, receiveMessageAck, observeMessage, refreshUnreads, @@ -301,7 +295,6 @@ export function useRealtimeAppState({ setChannels, setContacts, setHealth, - setRawPackets, notifyIncomingMessage, ] ); diff --git a/frontend/src/stores/rawPacketStore.ts b/frontend/src/stores/rawPacketStore.ts new file mode 100644 index 0000000..cee4c3f --- /dev/null +++ b/frontend/src/stores/rawPacketStore.ts @@ -0,0 +1,144 @@ +import { useSyncExternalStore } from 'react'; + +import type { RawPacket } from '../types'; +import { appendRawPacketUnique } from '../utils/rawPacketIdentity'; +import { + MAX_RAW_PACKET_STATS_OBSERVATIONS, + summarizeRawPacketForStats, + type RawPacketStatsSessionState, +} from '../utils/rawPacketStats'; + +/** + * Live radio traffic arrives continuously — every packet the node overhears, not + * just our own conversations. Holding that stream in App state re-rendered the whole + * tree (including the message list) on every packet, which made typing crawl in + * conversations with a lot of history. + * + * The stream lives here instead, outside React, so only the views that actually read + * packets (map, visualizer, raw feed, cracker) re-render when one arrives. + */ + +export const MAX_RAW_PACKETS = 500; + +function createStatsSession(): RawPacketStatsSessionState { + return { + sessionStartedAt: Date.now(), + totalObservedPackets: 0, + trimmedObservationCount: 0, + observations: [], + }; +} + +let packets: RawPacket[] = []; +let statsSession: RawPacketStatsSessionState = createStatsSession(); +const listeners = new Set<() => void>(); + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function emit(): void { + for (const listener of listeners) { + listener(); + } +} + +function observePacket( + session: RawPacketStatsSessionState, + packet: RawPacket +): RawPacketStatsSessionState { + const observation = summarizeRawPacketForStats(packet); + if ( + session.observations.some( + (candidate) => candidate.observationKey === observation.observationKey + ) + ) { + return session; + } + + const observations = [...session.observations, observation]; + if (observations.length <= MAX_RAW_PACKET_STATS_OBSERVATIONS) { + return { + ...session, + totalObservedPackets: session.totalObservedPackets + 1, + observations, + }; + } + + const overflow = observations.length - MAX_RAW_PACKET_STATS_OBSERVATIONS; + return { + ...session, + totalObservedPackets: session.totalObservedPackets + 1, + trimmedObservationCount: session.trimmedObservationCount + overflow, + observations: observations.slice(overflow), + }; +} + +/** Record one observed packet into both the rolling buffer and the session stats. */ +export function recordRawPacket(packet: RawPacket, maxPackets: number = MAX_RAW_PACKETS): void { + const nextPackets = appendRawPacketUnique(packets, packet, maxPackets); + const nextStats = observePacket(statsSession, packet); + if (nextPackets === packets && nextStats === statsSession) { + return; + } + + packets = nextPackets; + statsSession = nextStats; + emit(); +} + +/** + * Drop the buffered packets on reconnect — we may have missed traffic while offline. + * Session stats deliberately survive: they describe the whole observation session. + */ +export function clearRawPackets(): void { + if (packets.length === 0) { + return; + } + packets = []; + emit(); +} + +/** Full reset, including session stats. Used by tests to isolate cases. */ +export function resetRawPacketStore(): void { + packets = []; + statsSession = createStatsSession(); + emit(); +} + +/** + * Install a specific stream/stats snapshot. Exists so tests can drive the views + * from fixed fixtures — including stats shapes that a live packet feed would take + * minutes to produce — without reintroducing prop drilling through the app tree. + */ +export function seedRawPacketStore(next: { + packets?: RawPacket[]; + statsSession?: RawPacketStatsSessionState; +}): void { + if (next.packets) { + packets = next.packets; + } + if (next.statsSession) { + statsSession = next.statsSession; + } + emit(); +} + +export function getRawPackets(): RawPacket[] { + return packets; +} + +export function getRawPacketStatsSession(): RawPacketStatsSessionState { + return statsSession; +} + +export function useRawPackets(): RawPacket[] { + return useSyncExternalStore(subscribe, getRawPackets); +} + +export function useRawPacketStatsSession(): RawPacketStatsSessionState { + return useSyncExternalStore(subscribe, getRawPacketStatsSession); +} diff --git a/frontend/src/test/crackerPanel.test.tsx b/frontend/src/test/crackerPanel.test.tsx index 8eb922c..efcb4ea 100644 --- a/frontend/src/test/crackerPanel.test.tsx +++ b/frontend/src/test/crackerPanel.test.tsx @@ -43,7 +43,7 @@ describe('CrackerPanel', () => { }); it('allows clearing max length while editing', async () => { - render(); + render(); await waitFor(() => { expect(mockedApi.getUndecryptedPacketCount).toHaveBeenCalled(); diff --git a/frontend/src/test/rawPacketFeedView.test.tsx b/frontend/src/test/rawPacketFeedView.test.tsx index 06078c1..02bc8d5 100644 --- a/frontend/src/test/rawPacketFeedView.test.tsx +++ b/frontend/src/test/rawPacketFeedView.test.tsx @@ -1,7 +1,8 @@ -import { fireEvent, render, screen } from '@testing-library/react'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { RawPacketFeedView } from '../components/RawPacketFeedView'; +import { resetRawPacketStore, seedRawPacketStore } from '../stores/rawPacketStore'; import type { RawPacketStatsSessionState } from '../utils/rawPacketStats'; import type { Channel, Contact, RawPacket } from '../types'; @@ -108,17 +109,15 @@ function renderView({ channels?: Channel[]; rawPacketStatsSession?: RawPacketStatsSessionState; } = {}) { - return render( - - ); + seedRawPacketStore({ packets, statsSession: rawPacketStatsSession }); + return render(); } describe('RawPacketFeedView', () => { + beforeEach(() => { + resetRawPacketStore(); + }); + afterEach(() => { vi.unstubAllGlobals(); }); @@ -225,7 +224,7 @@ describe('RawPacketFeedView', () => { ], }); - const { rerender } = renderView({ + renderView({ packets: initialPackets, rawPacketStatsSession: initialSession, contacts: [], @@ -236,14 +235,7 @@ describe('RawPacketFeedView', () => { expect(screen.getByText(/only covered for 10 sec/i)).toBeInTheDocument(); vi.setSystemTime(new Date('2024-01-01T00:01:10Z')); - rerender( - - ); + act(() => seedRawPacketStore({ packets: nextPackets, statsSession: initialSession })); expect(screen.getByText(/only covered for 50 sec/i)).toBeInTheDocument(); vi.setSystemTime(new Date('2024-01-01T00:01:30Z')); @@ -257,14 +249,7 @@ describe('RawPacketFeedView', () => { }, ], }; - rerender( - - ); + act(() => seedRawPacketStore({ packets: nextPackets, statsSession: nextSession })); expect(screen.getByText(/only covered for 10 sec/i)).toBeInTheDocument(); vi.useRealTimers(); diff --git a/frontend/src/test/rawPacketStore.test.tsx b/frontend/src/test/rawPacketStore.test.tsx new file mode 100644 index 0000000..64ef5f6 --- /dev/null +++ b/frontend/src/test/rawPacketStore.test.tsx @@ -0,0 +1,226 @@ +import { act, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ConversationPane } from '../components/ConversationPane'; +import { + clearRawPackets, + getRawPacketStatsSession, + getRawPackets, + recordRawPacket, + resetRawPacketStore, + useRawPackets, +} from '../stores/rawPacketStore'; +import type { + Channel, + Contact, + Conversation, + HealthStatus, + Message, + RadioConfig, + RawPacket, +} from '../types'; +import type { RawPacketStatsSessionState } from '../utils/rawPacketStats'; + +const mocks = vi.hoisted(() => ({ + messageList: vi.fn(() =>
), +})); + +vi.mock('../components/MessageList', () => ({ + MessageList: mocks.messageList, +})); + +vi.mock('../components/ChatHeader', () => ({ + ChatHeader: () =>
, +})); + +function createPacket(overrides: Partial = {}): RawPacket { + return { + id: 1, + observation_id: 1, + timestamp: 1700000000, + data: 'aabb', + payload_type: 'GROUP_TEXT', + snr: 7.5, + rssi: -80, + decrypted: false, + decrypted_info: null, + ...overrides, + }; +} + +const channel: Channel = { + key: '8B3387E9C5CDEA6AC9E5EDBAA115CD72', + name: 'Public', + is_hashtag: false, + on_radio: false, + last_read_at: null, + favorite: false, + muted: false, +}; + +const config: RadioConfig = { + public_key: 'aa'.repeat(32), + name: 'Radio', + lat: 1, + lon: 2, + tx_power: 17, + max_tx_power: 22, + radio: { freq: 910.525, bw: 62.5, sf: 7, cr: 5 }, + path_hash_mode: 0, + path_hash_mode_supported: true, +}; + +const health: HealthStatus = { + status: 'ok', + radio_connected: true, + radio_initializing: false, + connection_info: 'serial', + database_size_mb: 1, + oldest_undecrypted_timestamp: null, + fanout_statuses: {}, + bots_disabled: false, +}; + +const message: Message = { + id: 1, + type: 'CHAN', + conversation_key: channel.key, + text: 'hello', + sender_timestamp: 1700000000, + received_at: 1700000001, + paths: null, + txt_type: 0, + signature: null, + sender_key: null, + outgoing: false, + acked: 0, + sender_name: null, +}; + +const rawPacketStatsSession: RawPacketStatsSessionState = { + sessionStartedAt: 1_700_000_000_000, + totalObservedPackets: 0, + trimmedObservationCount: 0, + observations: [], +}; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +function chatPaneProps(): any { + return { + activeConversation: { type: 'channel', id: channel.key, name: channel.name } as Conversation, + contacts: [] as Contact[], + channels: [channel], + rawPacketStatsSession, + config, + health, + notificationsSupported: true, + notificationsEnabled: false, + notificationsPermission: 'granted' as const, + messages: [message], + messagesLoading: false, + loadingOlder: false, + hasOlderMessages: false, + unreadMarkerLastReadAt: undefined, + targetMessageId: null, + hasNewerMessages: false, + loadingNewer: false, + messageInputRef: { current: null }, + onTrace: vi.fn(async () => {}), + onRunTracePath: vi.fn(async () => ({ path_len: 0, timeout_seconds: 5, nodes: [] })), + onPathDiscovery: vi.fn(async () => { + throw new Error('unused'); + }), + onToggleFavorite: vi.fn(async () => {}), + onToggleMute: vi.fn(async () => {}), + onDeleteContact: vi.fn(async () => {}), + onDeleteChannel: vi.fn(async () => {}), + onSetChannelFloodScopeOverride: vi.fn(async () => {}), + onSelectConversation: vi.fn(), + onOpenContactInfo: vi.fn(), + onOpenChannelInfo: vi.fn(), + onSenderClick: vi.fn(), + onLoadOlder: vi.fn(async () => {}), + onResendChannelMessage: vi.fn(async () => {}), + onTargetReached: vi.fn(), + onLoadNewer: vi.fn(async () => {}), + onJumpToBottom: vi.fn(), + onDismissUnreadMarker: vi.fn(), + onSendMessage: vi.fn(async () => {}), + onToggleNotifications: vi.fn(), + trackedTelemetryRepeaters: [], + onToggleTrackedTelemetry: vi.fn(async () => {}), + repeaterAutoLoginKey: null, + onClearRepeaterAutoLogin: vi.fn(), + }; +} + +describe('rawPacketStore', () => { + beforeEach(() => { + vi.clearAllMocks(); + resetRawPacketStore(); + }); + + it('appends packets and counts them in the session stats', () => { + recordRawPacket(createPacket({ id: 1, observation_id: 1 })); + recordRawPacket(createPacket({ id: 2, observation_id: 2 })); + + expect(getRawPackets()).toHaveLength(2); + expect(getRawPacketStatsSession().totalObservedPackets).toBe(2); + }); + + it('ignores a repeat of the same observation', () => { + recordRawPacket(createPacket({ id: 1, observation_id: 9 })); + recordRawPacket(createPacket({ id: 1, observation_id: 9 })); + + expect(getRawPackets()).toHaveLength(1); + expect(getRawPacketStatsSession().totalObservedPackets).toBe(1); + }); + + it('caps the buffer at the requested size, keeping the newest packets', () => { + for (let i = 1; i <= 5; i++) { + recordRawPacket(createPacket({ id: i, observation_id: i }), 3); + } + + expect(getRawPackets().map((p) => p.id)).toEqual([3, 4, 5]); + }); + + it('keeps session stats across a reconnect clear', () => { + recordRawPacket(createPacket({ id: 1, observation_id: 1 })); + clearRawPackets(); + + expect(getRawPackets()).toEqual([]); + expect(getRawPacketStatsSession().totalObservedPackets).toBe(1); + }); + + it('notifies subscribed views when a packet arrives', () => { + function PacketCount() { + return {useRawPackets().length}; + } + render(); + expect(screen.getByTestId('count').textContent).toBe('0'); + + act(() => recordRawPacket(createPacket({ id: 1, observation_id: 1 }))); + + expect(screen.getByTestId('count').textContent).toBe('1'); + }); + + /** + * The reason this store exists: overheard traffic used to live in App state, so every + * packet re-rendered the whole message list. That cost scales with history length and + * made typing in a busy channel crawl. + */ + it('does not re-render the chat message list when packets arrive', () => { + render(); + expect(screen.getByTestId('message-list')).toBeInTheDocument(); + + const rendersAfterMount = mocks.messageList.mock.calls.length; + act(() => { + for (let i = 1; i <= 25; i++) { + recordRawPacket(createPacket({ id: i, observation_id: i })); + } + }); + + expect(getRawPackets()).toHaveLength(25); + expect(mocks.messageList.mock.calls.length).toBe(rendersAfterMount); + }); +}); diff --git a/frontend/src/test/useRealtimeAppState.test.ts b/frontend/src/test/useRealtimeAppState.test.ts index 66f0463..6fca6d2 100644 --- a/frontend/src/test/useRealtimeAppState.test.ts +++ b/frontend/src/test/useRealtimeAppState.test.ts @@ -2,6 +2,12 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { useRealtimeAppState } from '../hooks/useRealtimeAppState'; +import { + getRawPacketStatsSession, + getRawPackets, + resetRawPacketStore, + seedRawPacketStore, +} from '../stores/rawPacketStore'; import type { Channel, Contact, Conversation, HealthStatus, Message, RawPacket } from '../types'; const mocks = vi.hoisted(() => ({ @@ -32,6 +38,18 @@ const publicChannel: Channel = { muted: false, }; +const rawPacketFixture: RawPacket = { + id: 1, + observation_id: 2, + timestamp: 1700000000, + data: 'aabb', + payload_type: 'GROUP_TEXT', + snr: 7.5, + rssi: -80, + decrypted: false, + decrypted_info: null, +}; + const incomingDm: Message = { id: 7, type: 'PRIV', @@ -50,7 +68,6 @@ const incomingDm: Message = { function createRealtimeArgs(overrides: Partial[0]> = {}) { const setHealth = vi.fn(); - const setRawPackets = vi.fn(); const setChannels = vi.fn(); const setContacts = vi.fn(); @@ -59,7 +76,6 @@ function createRealtimeArgs(overrides: Partial {}), setChannels, @@ -84,7 +100,6 @@ function createRealtimeArgs(overrides: Partial { beforeEach(() => { vi.clearAllMocks(); + resetRawPacketStore(); mocks.api.getChannels.mockResolvedValue([publicChannel]); }); @@ -125,6 +141,8 @@ describe('useRealtimeAppState', () => { const { result } = renderHook(() => useRealtimeAppState(args)); + seedRawPacketStore({ packets: [rawPacketFixture] }); + act(() => { result.current.onReconnect?.(); }); @@ -134,7 +152,7 @@ describe('useRealtimeAppState', () => { expect(args.refreshUnreads).toHaveBeenCalledTimes(1); expect(mocks.api.getChannels).toHaveBeenCalledTimes(1); expect(args.fetchAllContacts).toHaveBeenCalledTimes(1); - expect(fns.setRawPackets).toHaveBeenCalledWith([]); + expect(getRawPackets()).toEqual([]); expect(fns.setChannels).toHaveBeenCalledWith([publicChannel]); expect(fns.setContacts).toHaveBeenCalledWith(contacts); }); @@ -168,6 +186,8 @@ describe('useRealtimeAppState', () => { const { result } = renderHook(() => useRealtimeAppState(args)); + seedRawPacketStore({ packets: [rawPacketFixture] }); + act(() => { result.current.onReconnect?.(); }); @@ -177,7 +197,7 @@ describe('useRealtimeAppState', () => { expect(args.refreshUnreads).toHaveBeenCalledTimes(1); expect(mocks.api.getChannels).toHaveBeenCalledTimes(1); expect(args.fetchAllContacts).toHaveBeenCalledTimes(1); - expect(fns.setRawPackets).toHaveBeenCalledWith([]); + expect(getRawPackets()).toEqual([]); expect(fns.setChannels).toHaveBeenCalledWith([publicChannel]); expect(fns.setContacts).toHaveBeenCalledWith(contacts); }); @@ -285,18 +305,8 @@ describe('useRealtimeAppState', () => { }); it('appends raw packets using observation identity dedup', () => { - const { args, fns } = createRealtimeArgs(); - const packet: RawPacket = { - id: 1, - observation_id: 2, - timestamp: 1700000000, - data: 'aabb', - payload_type: 'GROUP_TEXT', - snr: 7.5, - rssi: -80, - decrypted: false, - decrypted_info: null, - }; + const { args } = createRealtimeArgs(); + const packet = rawPacketFixture; const { result } = renderHook(() => useRealtimeAppState(args)); @@ -304,6 +314,7 @@ describe('useRealtimeAppState', () => { result.current.onRawPacket?.(packet); }); - expect(fns.setRawPackets).toHaveBeenCalledWith(expect.any(Function)); + expect(getRawPackets()).toEqual([packet]); + expect(getRawPacketStatsSession().totalObservedPackets).toBe(1); }); }); diff --git a/frontend/src/test/visualizerView.test.tsx b/frontend/src/test/visualizerView.test.tsx index 7da14c9..9d21d50 100644 --- a/frontend/src/test/visualizerView.test.tsx +++ b/frontend/src/test/visualizerView.test.tsx @@ -2,6 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { VisualizerView } from '../components/VisualizerView'; +import { resetRawPacketStore, seedRawPacketStore } from '../stores/rawPacketStore'; import type { RawPacket } from '../types'; // The 3D scene needs WebGL, which jsdom does not provide. @@ -26,17 +27,12 @@ function createPacket(overrides: Partial = {}): RawPacket { describe('VisualizerView packet feed', () => { beforeEach(() => { window.localStorage.clear(); + resetRawPacketStore(); }); it('opens the packet analyzer when a feed packet is clicked', () => { - render( - - ); + seedRawPacketStore({ packets: [createPacket({ id: 7, observation_id: 21 })] }); + render(); expect(screen.queryByText('Packet Details')).not.toBeInTheDocument(); @@ -47,7 +43,7 @@ describe('VisualizerView packet feed', () => { }); it('does not render the analyzer until a packet is selected', () => { - render(); + render(); expect(screen.queryByText('Packet Details')).not.toBeInTheDocument(); }); From 4559fd34c2135694fd119f526cf2675c38755507 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Sat, 25 Jul 2026 15:12:21 -0700 Subject: [PATCH 2/2] Test improvements and trimming --- frontend/AGENTS.md | 7 +- frontend/src/hooks/useRealtimeAppState.ts | 5 +- frontend/src/stores/rawPacketStore.ts | 12 +- frontend/src/test/appPacketIsolation.test.tsx | 239 ++++++++++++++++++ frontend/src/test/rawPacketStore.test.tsx | 87 +++++++ 5 files changed, 345 insertions(+), 5 deletions(-) create mode 100644 frontend/src/test/appPacketIsolation.test.tsx diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 966e196..9231f9b 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -44,6 +44,8 @@ frontend/src/ │ └── PushSubscriptionContext.tsx # Push subscription state context/provider ├── lib/ │ └── utils.ts # cn() — clsx + tailwind-merge helper +├── stores/ +│ └── rawPacketStore.ts # Overheard packet stream + session stats, outside React ├── hooks/ │ ├── index.ts # Central re-export of all hooks │ ├── useConversationActions.ts # Send/resend/trace/block conversation actions @@ -60,7 +62,6 @@ frontend/src/ │ ├── useBrowserNotifications.ts # Per-conversation browser notification preferences + dispatch │ ├── usePushSubscription.ts # Web Push subscription lifecycle, per-conversation filters │ ├── useFaviconBadge.ts # Browser tab unread badge state -│ ├── useRawPacketStatsSession.ts # Session-scoped packet-feed stats history │ └── useRememberedServerPassword.ts # Browser-local repeater/room password persistence ├── components/ │ ├── AppShell.tsx # App-shell layout: status, sidebar, search/settings panes, cracker, modals, security warning @@ -249,6 +250,10 @@ High-level state is delegated to hooks: `App.tsx` intentionally still does the final `AppShell` prop assembly. That composition layer is considered acceptable here because it keeps the shell contract visible in one place and avoids a prop-bundling hook with little original logic. +**The overheard packet stream is the one piece of app state that deliberately does not live in React.** It is held in `stores/rawPacketStore.ts` and read through `useSyncExternalStore`, because it updates several times a second with every packet the node hears — far more often than anything else — and only four surfaces consume it (`MapView`, `VisualizerView`, `RawPacketFeedView`, `CrackerPanel`). Held in `App` state it re-rendered the entire tree, including `MessageList`, which is neither memoized nor cheap on a long history. + +That gives the store a load-bearing invariant: **no ancestor of `MessageList` may call `useRawPackets()` / `useRawPacketStatsSession()`.** Nothing about the prop signatures enforces it — an innocuous-looking subscription added to `App`, `AppShell`, or `ConversationPane` silently restores the original slowdown. `src/test/appPacketIsolation.test.tsx` pins it by mounting the real ancestor chain and asserting `MessageList` does not re-render when packets arrive; it carries a negative control so the assertion cannot pass vacuously. Reach for packets in a new view by subscribing in that view, never by lifting them up. + `ConversationPane.tsx` owns the main active-conversation surface branching: - empty state - map view diff --git a/frontend/src/hooks/useRealtimeAppState.ts b/frontend/src/hooks/useRealtimeAppState.ts index e37034e..f66d428 100644 --- a/frontend/src/hooks/useRealtimeAppState.ts +++ b/frontend/src/hooks/useRealtimeAppState.ts @@ -11,7 +11,7 @@ import { toast } from '../components/ui/sonner'; import { getStateKey } from '../utils/conversationState'; import { mergeContactIntoList } from '../utils/contactMerge'; import { getContactDisplayName } from '../utils/pubkey'; -import { clearRawPackets, recordRawPacket } from '../stores/rawPacketStore'; +import { clearRawPackets, MAX_RAW_PACKETS, recordRawPacket } from '../stores/rawPacketStore'; import { emitStatusDotPulse } from '../utils/statusDotPulse'; import type { Channel, @@ -57,6 +57,7 @@ interface UseRealtimeAppStateArgs { packetId?: number | null ) => void; notifyIncomingMessage?: (msg: Message) => void; + /** Buffer cap override. Defaults to the store's own cap; tests use it to force eviction. */ maxRawPackets?: number; } @@ -105,7 +106,7 @@ export function useRealtimeAppState({ removeConversationMessages, receiveMessageAck, notifyIncomingMessage, - maxRawPackets = 500, + maxRawPackets = MAX_RAW_PACKETS, }: UseRealtimeAppStateArgs): UseWebSocketOptions { const mergeChannelIntoList = useCallback( (updated: Channel) => { diff --git a/frontend/src/stores/rawPacketStore.ts b/frontend/src/stores/rawPacketStore.ts index cee4c3f..0cdc18c 100644 --- a/frontend/src/stores/rawPacketStore.ts +++ b/frontend/src/stores/rawPacketStore.ts @@ -118,11 +118,19 @@ export function seedRawPacketStore(next: { packets?: RawPacket[]; statsSession?: RawPacketStatsSessionState; }): void { + // Copy rather than alias. The whole store rests on "a snapshot is immutable, and its + // identity changes only when its contents do". Holding the caller's array would let + // them mutate the live snapshot in place, and because useSyncExternalStore compares + // snapshots with Object.is, React would then bail out of every subsequent render — + // leaving the UI permanently disagreeing with getRawPackets() and no way to tell why. if (next.packets) { - packets = next.packets; + packets = [...next.packets]; } if (next.statsSession) { - statsSession = next.statsSession; + statsSession = { + ...next.statsSession, + observations: [...next.statsSession.observations], + }; } emit(); } diff --git a/frontend/src/test/appPacketIsolation.test.tsx b/frontend/src/test/appPacketIsolation.test.tsx new file mode 100644 index 0000000..320cdaa --- /dev/null +++ b/frontend/src/test/appPacketIsolation.test.tsx @@ -0,0 +1,239 @@ +/** + * The invariant this store exists to protect: nothing on the chat render path may + * subscribe to the raw packet stream. + * + * The original bug was not that the chat view read packets — it never did. It was that + * the stream lived in `App` state, and `App` is an *ancestor* of `MessageList`. Nothing + * on that path is memoized, so every overheard packet re-rendered the whole message list + * regardless of which props it actually received. + * + * That means the regression can only be caught by mounting the real ancestor chain + * (`App` → `AppShell` → `ConversationPane` → `MessageList`). A test that renders + * `ConversationPane` on its own cannot see it: the offending subscription lives above. + */ +import React from 'react'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + messageList: vi.fn(() =>
), + api: { + getRadioConfig: vi.fn(), + getSettings: vi.fn(), + getUndecryptedPacketCount: vi.fn(), + getChannels: vi.fn(), + getContacts: vi.fn(), + getHealth: vi.fn(), + }, + hookFns: { + observeMessage: vi.fn(() => ({ added: false, activeConversation: false })), + refreshUnreads: vi.fn(async () => {}), + }, +})); + +vi.mock('../api', () => ({ api: mocks.api })); + +vi.mock('../useWebSocket', () => ({ useWebSocket: vi.fn() })); + +vi.mock('../contexts/PushSubscriptionContext', () => ({ + usePush: () => ({ + isSupported: false, + isSubscribed: false, + currentSubscriptionId: null, + allSubscriptions: [], + pushConversations: [], + loading: false, + subscribe: vi.fn(async () => null), + unsubscribe: vi.fn(async () => {}), + toggleConversation: vi.fn(async () => {}), + isConversationPushEnabled: () => false, + deleteSubscription: vi.fn(async () => {}), + testPush: vi.fn(async () => {}), + refreshSubscriptions: vi.fn(async () => []), + refreshConversations: vi.fn(async () => {}), + }), +})); + +vi.mock('../hooks', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useConversationMessages: () => ({ + messages: [], + messagesLoading: false, + loadingOlder: false, + hasOlderMessages: false, + hasNewerMessages: false, + loadingNewer: false, + fetchOlderMessages: vi.fn(async () => {}), + fetchNewerMessages: vi.fn(async () => {}), + jumpToBottom: vi.fn(), + reloadCurrentConversation: vi.fn(), + observeMessage: mocks.hookFns.observeMessage, + receiveMessageAck: vi.fn(), + reconcileOnReconnect: vi.fn(), + renameConversationMessages: vi.fn(), + removeConversationMessages: vi.fn(), + clearConversationMessages: vi.fn(), + }), + useUnreadCounts: () => ({ + unreadCounts: {}, + mentions: {}, + lastMessageTimes: {}, + unreadLastReadAts: {}, + recordMessageEvent: vi.fn(), + renameConversationState: vi.fn(), + removeConversationState: vi.fn(), + markAllRead: vi.fn(), + refreshUnreads: mocks.hookFns.refreshUnreads, + }), + }; +}); + +// Mocked to keep the tree small and deterministic. None of them are mounted while a +// chat conversation is active, so removing them cannot mask a chat-path subscription. +vi.mock('../components/StatusBar', () => ({ StatusBar: () =>
})); +vi.mock('../components/Sidebar', () => ({ Sidebar: () =>
})); +vi.mock('../components/MessageList', () => ({ MessageList: mocks.messageList })); +vi.mock('../components/MessageInput', () => ({ + MessageInput: React.forwardRef((_props, ref) => { + React.useImperativeHandle(ref, () => ({ appendText: vi.fn(), focus: vi.fn() })); + return
; + }), +})); +vi.mock('../components/NewMessageModal', () => ({ NewMessageModal: () => null })); +vi.mock('../components/SettingsModal', () => ({ + SettingsModal: () => null, + SETTINGS_SECTION_ORDER: ['radio'], + SETTINGS_SECTION_LABELS: { radio: 'Radio' }, +})); +vi.mock('../components/MapView', () => ({ MapView: () => null })); +vi.mock('../components/VisualizerView', () => ({ VisualizerView: () => null })); +vi.mock('../components/CrackerPanel', () => ({ CrackerPanel: () => null })); +vi.mock('../components/ui/sonner', () => ({ + Toaster: () => null, + toast: { success: vi.fn(), error: vi.fn() }, +})); +vi.mock('../utils/urlHash', () => ({ + parseHashConversation: () => null, + parseHashSettingsSection: () => null, + updateUrlHash: vi.fn(), + pushUrlHash: vi.fn(), + updateSettingsHash: vi.fn(), + pushSettingsHash: vi.fn(), + getSettingsHash: (section: string) => `#settings/${section}`, + getMapFocusHash: () => '#map', +})); + +import { App } from '../App'; +import { + getRawPackets, + recordRawPacket, + resetRawPacketStore, + useRawPackets, +} from '../stores/rawPacketStore'; +import type { RawPacket } from '../types'; + +function createPacket(overrides: Partial = {}): RawPacket { + return { + id: 1, + observation_id: 1, + timestamp: 1700000000, + data: 'aabb', + payload_type: 'GROUP_TEXT', + snr: 7.5, + rssi: -80, + decrypted: false, + decrypted_info: null, + ...overrides, + }; +} + +const publicChannel = { + key: '8B3387E9C5CDEA6AC9E5EDBAA115CD72', + name: 'Public', + is_hashtag: false, + on_radio: false, + last_read_at: null, + favorite: false, + muted: false, +}; + +describe('overheard packets and the chat render path', () => { + beforeEach(() => { + vi.clearAllMocks(); + resetRawPacketStore(); + mocks.api.getRadioConfig.mockResolvedValue({ + public_key: 'aa'.repeat(32), + name: 'TestNode', + lat: 0, + lon: 0, + tx_power: 17, + max_tx_power: 22, + radio: { freq: 910.525, bw: 62.5, sf: 7, cr: 5 }, + path_hash_mode: 0, + path_hash_mode_supported: false, + }); + mocks.api.getSettings.mockResolvedValue({ + max_radio_contacts: 200, + auto_decrypt_dm_on_advert: false, + last_message_times: {}, + advert_interval: 0, + last_advert_time: 0, + flood_scope: '', + known_regions: [], + blocked_keys: [], + blocked_names: [], + }); + mocks.api.getUndecryptedPacketCount.mockResolvedValue({ count: 0 }); + mocks.api.getChannels.mockResolvedValue([publicChannel]); + mocks.api.getContacts.mockResolvedValue([]); + mocks.api.getHealth.mockResolvedValue(null); + }); + + it('does not re-render the message list when packets arrive', async () => { + render(); + await waitFor(() => { + expect(screen.getByTestId('message-list')).toBeInTheDocument(); + }); + + const rendersBefore = mocks.messageList.mock.calls.length; + act(() => { + for (let i = 1; i <= 25; i++) { + recordRawPacket(createPacket({ id: i, observation_id: i })); + } + }); + + // Guards the assertion below against passing for the wrong reason + expect(getRawPackets()).toHaveLength(25); + expect(mocks.messageList.mock.calls.length).toBe(rendersBefore); + }); + + it('re-renders the message list once per batch if an ancestor subscribes', async () => { + // Negative control. Proves the assertion above can actually fail — without this, a + // render counter that never increments would look identical to a passing test. + // + // The ancestor has to create the element inside its own render. Taking it as + // a `children` prop would defeat the point: that element is built once by the caller, + // so its identity never changes and React bails out of the subtree — the test would + // pass while proving nothing. + function SubscribingAncestor() { + useRawPackets(); + return ; + } + + render(); + await waitFor(() => { + expect(screen.getByTestId('message-list')).toBeInTheDocument(); + }); + + const rendersBefore = mocks.messageList.mock.calls.length; + act(() => { + for (let i = 1; i <= 25; i++) { + recordRawPacket(createPacket({ id: i, observation_id: i })); + } + }); + + expect(mocks.messageList.mock.calls.length).toBeGreaterThan(rendersBefore); + }); +}); diff --git a/frontend/src/test/rawPacketStore.test.tsx b/frontend/src/test/rawPacketStore.test.tsx index 64ef5f6..a7e4139 100644 --- a/frontend/src/test/rawPacketStore.test.tsx +++ b/frontend/src/test/rawPacketStore.test.tsx @@ -8,8 +8,10 @@ import { getRawPackets, recordRawPacket, resetRawPacketStore, + seedRawPacketStore, useRawPackets, } from '../stores/rawPacketStore'; +import { MAX_RAW_PACKET_STATS_OBSERVATIONS } from '../utils/rawPacketStats'; import type { Channel, Contact, @@ -48,6 +50,28 @@ function createPacket(overrides: Partial = {}): RawPacket { }; } +/** A stats session already holding `count` distinct observations, for trim-boundary tests. */ +function sessionAtObservationCap(count: number): RawPacketStatsSessionState { + return { + sessionStartedAt: 1700000000000, + totalObservedPackets: count, + trimmedObservationCount: 0, + observations: Array.from({ length: count }, (_, i) => ({ + observationKey: `seeded-${i}`, + timestamp: 1700000000 + i, + payloadType: 'GROUP_TEXT', + routeType: 'Flood', + decrypted: false, + rssi: null, + snr: null, + sourceKey: null, + sourceLabel: null, + pathTokenCount: 0, + pathSignature: null, + })), + }; +} + const channel: Channel = { key: '8B3387E9C5CDEA6AC9E5EDBAA115CD72', name: 'Public', @@ -204,6 +228,69 @@ describe('rawPacketStore', () => { expect(screen.getByTestId('count').textContent).toBe('1'); }); + /** + * Asserted through a mounted subscriber rather than getRawPackets(), because the + * failure mode is specifically a missing emit(): the module state would be correct + * while every view kept rendering packets that no longer exist. On a quiet mesh the + * next packet — and so the next repaint — can be minutes away. + */ + it('notifies subscribed views when the buffer is cleared on reconnect', () => { + function PacketCount() { + return {useRawPackets().length}; + } + act(() => recordRawPacket(createPacket({ id: 1, observation_id: 1 }))); + render(); + expect(screen.getByTestId('count').textContent).toBe('1'); + + act(() => clearRawPackets()); + + expect(screen.getByTestId('count').textContent).toBe('0'); + }); + + it('does not hand out a snapshot the seeding caller can still mutate', () => { + const fixture = [createPacket({ id: 1, observation_id: 1 })]; + seedRawPacketStore({ packets: fixture }); + + fixture.push(createPacket({ id: 2, observation_id: 2 })); + + // Aliasing the caller's array would mutate the live snapshot in place. Because + // useSyncExternalStore compares snapshots with Object.is, the identity would not + // change and React would bail out of every later render for good. + expect(getRawPackets()).toHaveLength(1); + }); + + /** + * MAX_RAW_PACKET_STATS_OBSERVATIONS is 20k, far past what a test can reach by + * recording, so the trim boundary is exercised by seeding a session that already + * sits on it. Without this, both the `<=` comparison and the trimmed-count + * arithmetic can be broken without any test noticing. + */ + it('retains exactly the observation cap before trimming starts', () => { + seedRawPacketStore({ + statsSession: sessionAtObservationCap(MAX_RAW_PACKET_STATS_OBSERVATIONS - 1), + }); + + recordRawPacket(createPacket({ id: 999999, observation_id: 999999 })); + + const session = getRawPacketStatsSession(); + expect(session.observations).toHaveLength(MAX_RAW_PACKET_STATS_OBSERVATIONS); + expect(session.trimmedObservationCount).toBe(0); + }); + + it('trims the oldest observation once the cap is exceeded', () => { + seedRawPacketStore({ + statsSession: sessionAtObservationCap(MAX_RAW_PACKET_STATS_OBSERVATIONS), + }); + + recordRawPacket(createPacket({ id: 999999, observation_id: 999999 })); + + const session = getRawPacketStatsSession(); + expect(session.observations).toHaveLength(MAX_RAW_PACKET_STATS_OBSERVATIONS); + expect(session.trimmedObservationCount).toBe(1); + // The evicted entry is the oldest, not the newest + expect(session.observations[0].observationKey).not.toBe('seeded-0'); + }); + /** * The reason this store exists: overheard traffic used to live in App state, so every * packet re-rendered the whole message list. That cost scales with history length and