One Socket, Three Destinations
Every live push in the app — a new camera image, a new panorama, an activity-log entry, a lease/config change, or an AI detection — travels through one SignalR connection, owned by a single Zustand store: stores/signalRStore.ts. Everything on this page is verified directly against that file.
Why this deserves its own page
signalRStore is easy to lose track of because it doesn't behave like a normal Zustand store. Most stores in this app own one slice of UI state. signalRStore owns a live network connection, registers five different server-push handlers on it, and — depending on which handler fires — either updates its own state or reaches directly into two other stores' state (cameraMovementStore, aiDetectionStore) via getState(), entirely outside React's render cycle. If you're debugging "why didn't the UI update" or "why did two different cameras' data cross-contaminate," this fan-out is almost always where to look first.
Walk through it
Click Next to walk through connecting, a broadcast fanning out to three different stores, and what happens when the connection drops.
Click any node in the diagram for the real class, table, or entity behind that box.
Three flows, matching the three things that actually happen to this connection over its lifetime:
- Connect & Join — the feature-flag-gated startup sequence, and how a Location View mounting turns into a
JoinGroupcall on the hub. - Broadcast Fan-out — the one place worth internalizing: one hub event, up to three different destinations, decided per-event-type, not configurably.
- Reconnect — what SignalR's own
withAutomaticReconnect()does for you, and the one piece it deliberately does not do for you (re-joining your camera group), whichsignalRStorehas to do by hand.
The five broadcast handlers, at a glance
| Server event | Where it lands | Gated? |
|---|---|---|
ReceiveImageBroadcast | signalRStore.cameraMetadataReceivedMessages (replaced, not appended) | No |
ReceivePanoImageBroadcast | signalRStore.panoCameraMetadataReceivedMessages (replaced) | No |
ReceiveActivityLogBroadcast | signalRStore.activityLogReceivedMessages (appended) | No |
ReceiveCurrentConfigBroadcast | cameraMovementStore.setLeaseFromSignalR(currentConfig, leaseModeEndDt) | Payload parsed manually (currentConfig={value}&leaseModeEndDt={value} or object form); only forwarded if currentConfig resolves to SINGLE | LEASED | TURBO | PANO |
BroadcastLatestAiDetectionToGroup | aiDetectionStore.mergeAiDetection(detection) | Yes — dropped entirely on this client if aiDetectionStore.isAIAlertModalOpen is false at the moment the broadcast arrives. It is not queued for when the modal opens later. |
The first three all live inside signalRStore's own state — that's most of what people expect from a "real-time store." The last two are the part that's easy to forget: they're side effects on other stores, fired from inside a hub-event callback, with no UI in between.
One connection, one group
joinCameraGroup guards against joining a group you're already in, and switchCameraGroup always leaves the previous group before joining a new one — a client is a member of at most one camera group at a time. This means Location View's live updates stop the instant you navigate to a different camera, by design; there's no multi-camera live subscription anywhere in the app today.
The reconnect gap
SignalR's client library retries a dropped transport on its own (withAutomaticReconnect()), so onreconnecting → onreconnected is mostly free. What is not free: the server does not remember which group you were in before the drop. signalRStore.onreconnected has to explicitly re-invoke JoinGroup for whatever currentCameraGroupId was set locally — skip that step and you'd have a client that looks connected (no error, no console warning) but silently stops receiving anything for the camera the operator is looking at.
If every automatic-reconnect attempt is exhausted, onclose fires instead — tracked as an exception, logged, and left as-is. There is currently no code path that re-initializes the connection from onclose; recovering from that state today requires a page reload.
Related
deep-dives/camera-lease-concurrency.md—ReceiveCurrentConfigBroadcastis how a lease taken by one operator becomes visible to every other operator watching that camera.features/camera-tile-grid— the tile grid deliberately does not use SignalR; it polls. Location View is the only surface that joins a group.