Merge pull request #339 from rgregg/perf/decouple-raw-packet-stream

Keep overheard packet traffic out of the chat render path
This commit is contained in:
Jack Kingsman
2026-07-25 15:23:08 -07:00
committed by GitHub
17 changed files with 777 additions and 171 deletions
+6 -1
View File
@@ -48,6 +48,8 @@ frontend/src/
│ └── utils.ts # cn() — clsx + tailwind-merge helper
├── networkGraph/
│ └── packetNetworkGraph.ts # Packet→network graph construction shared by visualizer surfaces
├── 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
@@ -64,7 +66,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
│ ├── useEntranceSettled.ts # Defers entrance animation work until layout settles
│ └── useRememberedServerPassword.ts # Browser-local repeater/room password persistence
├── components/
@@ -259,6 +260,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
+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,
};
}
+5 -11
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, MAX_RAW_PACKETS, 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,7 @@ interface UseRealtimeAppStateArgs {
packetId?: number | null
) => void;
notifyIncomingMessage?: (msg: Message) => void;
recordRawPacketObservation?: (packet: RawPacket) => void;
/** Buffer cap override. Defaults to the store's own cap; tests use it to force eviction. */
maxRawPackets?: number;
}
@@ -87,7 +86,6 @@ export function useRealtimeAppState({
prevHealthRef,
setHealth,
fetchConfig,
setRawPackets,
reconcileOnReconnect,
refreshUnreads,
setChannels,
@@ -108,8 +106,7 @@ export function useRealtimeAppState({
removeConversationMessages,
receiveMessageAck,
notifyIncomingMessage,
recordRawPacketObservation,
maxRawPackets = 500,
maxRawPackets = MAX_RAW_PACKETS,
}: UseRealtimeAppStateArgs): UseWebSocketOptions {
const mergeChannelIntoList = useCallback(
(updated: Channel) => {
@@ -180,7 +177,7 @@ export function useRealtimeAppState({
});
},
onReconnect: () => {
setRawPackets([]);
clearRawPackets();
reconcileOnReconnect();
refreshUnreads();
api.getChannels().then(setChannels).catch(console.error);
@@ -263,9 +260,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 +287,6 @@ export function useRealtimeAppState({
pendingDeleteFallbackRef,
prevHealthRef,
recordMessageEvent,
recordRawPacketObservation,
receiveMessageAck,
observeMessage,
refreshUnreads,
@@ -301,7 +296,6 @@ export function useRealtimeAppState({
setChannels,
setContacts,
setHealth,
setRawPackets,
notifyIncomingMessage,
]
);
+152
View File
@@ -0,0 +1,152 @@
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 {
// 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];
}
if (next.statsSession) {
statsSession = {
...next.statsSession,
observations: [...next.statsSession.observations],
};
}
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);
}
@@ -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(() => <div data-testid="message-list" />),
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<typeof import('../hooks')>();
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: () => <div data-testid="status-bar" /> }));
vi.mock('../components/Sidebar', () => ({ Sidebar: () => <div data-testid="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 <div data-testid="message-input" />;
}),
}));
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> = {}): 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(<App />);
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 <App /> 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 <App />;
}
render(<SubscribingAncestor />);
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);
});
});
+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();
+313
View File
@@ -0,0 +1,313 @@
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,
seedRawPacketStore,
useRawPackets,
} from '../stores/rawPacketStore';
import { MAX_RAW_PACKET_STATS_OBSERVATIONS } from '../utils/rawPacketStats';
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,
};
}
/** 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',
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');
});
/**
* 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 <span data-testid="count">{useRawPackets().length}</span>;
}
act(() => recordRawPacket(createPacket({ id: 1, observation_id: 1 })));
render(<PacketCount />);
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
* 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();
});