From 5e5ab7db4d1c276a6489c6c4838c20b6221f21cf Mon Sep 17 00:00:00 2001 From: Ryan Gregg Date: Sat, 25 Jul 2026 00:20:48 -0700 Subject: [PATCH] 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(); });