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.
This commit is contained in:
Ryan Gregg
2026-07-25 00:20:48 -07:00
parent 889bb19ef5
commit 5e5ab7db4d
15 changed files with 435 additions and 169 deletions
+1 -15
View File
@@ -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<MessageInputHandle>(null);
const [rawPackets, setRawPackets] = useState<RawPacket[]>([]);
const [channelUnreadMarker, setChannelUnreadMarker] = useState<ChannelUnreadMarker | null>(null);
const [newMessagePrefillRequest, setNewMessagePrefillRequest] =
useState<NewMessagePrefillRequest | null>(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,
};
+2 -21
View File
@@ -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({
<MapView
contacts={contacts}
focusedKey={activeConversation.mapFocusKey}
rawPackets={rawPackets}
config={config}
blockedKeys={blockedKeys}
blockedNames={blockedNames}
@@ -242,25 +235,13 @@ export function ConversationPane({
if (activeConversation.type === 'visualizer') {
return (
<Suspense fallback={<LoadingPane label="Loading visualizer..." />}>
<VisualizerView
packets={rawPackets}
contacts={contacts}
channels={channels}
config={config}
/>
<VisualizerView contacts={contacts} channels={channels} config={config} />
</Suspense>
);
}
if (activeConversation.type === 'raw') {
return (
<RawPacketFeedView
packets={rawPackets}
rawPacketStatsSession={rawPacketStatsSession}
contacts={contacts}
channels={channels}
/>
);
return <RawPacketFeedView contacts={contacts} channels={channels} />;
}
if (activeConversation.type === 'search') {
+2 -2
View File
@@ -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<void>;
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');
+3 -3
View File
@@ -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<MapSinceId>(getSavedSinceId);
const [customSince, setCustomSince] = useState('');
const [nowSec, setNowSec] = useState(() => Date.now() / 1000);
@@ -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
+3 -2
View File
@@ -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<RawPacket | null>(null);
-1
View File
@@ -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';
@@ -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<RawPacketStatsSessionState>(() => ({
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,
};
}
+3 -10
View File
@@ -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<HealthStatus | null>;
setHealth: Dispatch<SetStateAction<HealthStatus | null>>;
fetchConfig: () => void | Promise<void>;
setRawPackets: Dispatch<SetStateAction<RawPacket[]>>;
reconcileOnReconnect: () => void;
refreshUnreads: () => Promise<void>;
setChannels: Dispatch<SetStateAction<Channel[]>>;
@@ -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,
]
);
+144
View File
@@ -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);
}
+1 -1
View File
@@ -43,7 +43,7 @@ describe('CrackerPanel', () => {
});
it('allows clearing max length while editing', async () => {
render(<CrackerPanel packets={[]} channels={[]} onChannelCreate={vi.fn()} visible={false} />);
render(<CrackerPanel channels={[]} onChannelCreate={vi.fn()} visible={false} />);
await waitFor(() => {
expect(mockedApi.getUndecryptedPacketCount).toHaveBeenCalled();
+12 -27
View File
@@ -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(
<RawPacketFeedView
packets={packets}
rawPacketStatsSession={rawPacketStatsSession}
contacts={contacts}
channels={channels}
/>
);
seedRawPacketStore({ packets, statsSession: rawPacketStatsSession });
return render(<RawPacketFeedView contacts={contacts} channels={channels} />);
}
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(
<RawPacketFeedView
packets={nextPackets}
rawPacketStatsSession={initialSession}
contacts={[]}
channels={[]}
/>
);
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(
<RawPacketFeedView
packets={nextPackets}
rawPacketStatsSession={nextSession}
contacts={[]}
channels={[]}
/>
);
act(() => seedRawPacketStore({ packets: nextPackets, statsSession: nextSession }));
expect(screen.getByText(/only covered for 10 sec/i)).toBeInTheDocument();
vi.useRealTimers();
+226
View File
@@ -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(() => <div data-testid="message-list" />),
}));
vi.mock('../components/MessageList', () => ({
MessageList: mocks.messageList,
}));
vi.mock('../components/ChatHeader', () => ({
ChatHeader: () => <div data-testid="chat-header" />,
}));
function createPacket(overrides: Partial<RawPacket> = {}): 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 <span data-testid="count">{useRawPackets().length}</span>;
}
render(<PacketCount />);
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(<ConversationPane {...chatPaneProps()} />);
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);
});
});
+29 -18
View File
@@ -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<Parameters<typeof useRealtimeAppState>[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<Parameters<typeof useRealtimeAppS
prevHealthRef: { current: null as HealthStatus | null },
setHealth,
fetchConfig: vi.fn(),
setRawPackets,
reconcileOnReconnect: vi.fn(),
refreshUnreads: vi.fn(async () => {}),
setChannels,
@@ -84,7 +100,6 @@ function createRealtimeArgs(overrides: Partial<Parameters<typeof useRealtimeAppS
},
fns: {
setHealth,
setRawPackets,
setChannels,
setContacts,
},
@@ -94,6 +109,7 @@ function createRealtimeArgs(overrides: Partial<Parameters<typeof useRealtimeAppS
describe('useRealtimeAppState', () => {
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);
});
});
+5 -9
View File
@@ -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> = {}): RawPacket {
describe('VisualizerView packet feed', () => {
beforeEach(() => {
window.localStorage.clear();
resetRawPacketStore();
});
it('opens the packet analyzer when a feed packet is clicked', () => {
render(
<VisualizerView
packets={[createPacket({ id: 7, observation_id: 21 })]}
contacts={[]}
channels={[]}
config={null}
/>
);
seedRawPacketStore({ packets: [createPacket({ id: 7, observation_id: 21 })] });
render(<VisualizerView contacts={[]} channels={[]} config={null} />);
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(<VisualizerView packets={[]} contacts={[]} channels={[]} config={null} />);
render(<VisualizerView contacts={[]} channels={[]} config={null} />);
expect(screen.queryByText('Packet Details')).not.toBeInTheDocument();
});