mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-10 02:29:19 +03:00
refactor websockets to remove react-tracked
react 19 removed useReducer eager bailout, which broke react-tracked.
react-tracked works by wrapping state in a JavaScript Proxy. When a component reads state.someField, the proxy records that access. On the next state update, it compares only the fields each component actually touched and skips re-renders if those fields are unchanged. Under the hood, this relies on useReducer — and in React 18, useReducer had an "eager bail-out" that short-circuited rendering when the new state was === to the old state. React 19 removed that optimization, so every dispatch now schedules a render regardless, and the proxy comparison runs too late to prevent it.
useSyncExternalStore is a React primitive (added in 18, stable in 19) designed for exactly this pattern: subscribing to an external store:
useSyncExternalStore(
subscribe, // (listener) => unsubscribe — called when the store changes
getSnapshot // () => value — returns the current value for this subscriber
)
React calls getSnapshot during render and compares the result with Object.is. If the value is the same reference, the component bails out — no re-render. The key difference from react-tracked is that this bail-out is built into React's reconciler, not bolted on via proxy tricks and useReducer.
The per-topic subscription model makes this efficient. Instead of one global store where every subscriber has to check if their fields changed, each useWs("some/topic", ...) call subscribes only to that topic's listener set. When a message arrives for front_door/detect/state, only components subscribed to that exact topic get their listener fired → React calls their getSnapshot → Object.is compares the value → bail-out if unchanged. Components watching back_yard/detect/state are never even notified.
This commit is contained in:
parent
b2c7840c29
commit
dc6973dcc2
81
web/src/api/WsProvider.tsx
Normal file
81
web/src/api/WsProvider.tsx
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import { baseUrl } from "./baseUrl";
|
||||||
|
import { ReactNode, useCallback, useEffect, useRef } from "react";
|
||||||
|
import { WsSendContext } from "./wsContext";
|
||||||
|
import type { Update } from "./wsContext";
|
||||||
|
import { bufferRawMessage, resetWsStore } from "./ws";
|
||||||
|
|
||||||
|
export function WsProvider({ children }: { children: ReactNode }) {
|
||||||
|
const wsUrl = `${baseUrl.replace(/^http/, "ws")}ws`;
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const reconnectAttempt = useRef(0);
|
||||||
|
const unmounted = useRef(false);
|
||||||
|
|
||||||
|
const sendJsonMessage = useCallback((msg: unknown) => {
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
wsRef.current.send(JSON.stringify(msg));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
unmounted.current = false;
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
if (unmounted.current) return;
|
||||||
|
|
||||||
|
const ws = new WebSocket(wsUrl);
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
reconnectAttempt.current = 0;
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({ topic: "onConnect", message: "", retain: false }),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Near-zero-cost handler: just buffer the raw string.
|
||||||
|
// All JSON.parse + state updates happen in a single rAF in ws.ts,
|
||||||
|
// giving React uninterrupted CPU time between frames.
|
||||||
|
ws.onmessage = (event: MessageEvent) => {
|
||||||
|
bufferRawMessage(event.data as string);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
if (unmounted.current) return;
|
||||||
|
const delay = Math.min(1000 * 2 ** reconnectAttempt.current, 30000);
|
||||||
|
reconnectAttempt.current++;
|
||||||
|
reconnectTimer.current = setTimeout(connect, delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
ws.close();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unmounted.current = true;
|
||||||
|
if (reconnectTimer.current) {
|
||||||
|
clearTimeout(reconnectTimer.current);
|
||||||
|
}
|
||||||
|
wsRef.current?.close();
|
||||||
|
resetWsStore();
|
||||||
|
};
|
||||||
|
}, [wsUrl]);
|
||||||
|
|
||||||
|
const send = useCallback(
|
||||||
|
(message: Update) => {
|
||||||
|
sendJsonMessage({
|
||||||
|
topic: message.topic,
|
||||||
|
payload: message.payload,
|
||||||
|
retain: message.retain,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[sendJsonMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WsSendContext.Provider value={send}>{children}</WsSendContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { baseUrl } from "./baseUrl";
|
import { baseUrl } from "./baseUrl";
|
||||||
import { SWRConfig } from "swr";
|
import { SWRConfig } from "swr";
|
||||||
import { WsProvider } from "./ws";
|
import { WsProvider } from "./WsProvider";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
import { isRedirectingToLogin, setRedirectingToLogin } from "./auth-redirect";
|
import { isRedirectingToLogin, setRedirectingToLogin } from "./auth-redirect";
|
||||||
|
|||||||
@ -1,6 +1,11 @@
|
|||||||
import { baseUrl } from "./baseUrl";
|
import {
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
useCallback,
|
||||||
import useWebSocket, { ReadyState } from "react-use-websocket";
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useSyncExternalStore,
|
||||||
|
} from "react";
|
||||||
import {
|
import {
|
||||||
EmbeddingsReindexProgressType,
|
EmbeddingsReindexProgressType,
|
||||||
FrigateCameraState,
|
FrigateCameraState,
|
||||||
@ -14,8 +19,11 @@ import {
|
|||||||
Job,
|
Job,
|
||||||
} from "@/types/ws";
|
} from "@/types/ws";
|
||||||
import { FrigateStats } from "@/types/stats";
|
import { FrigateStats } from "@/types/stats";
|
||||||
import { createContainer } from "react-tracked";
|
import { isEqual } from "lodash";
|
||||||
import useDeepMemo from "@/hooks/use-deep-memo";
|
import { WsSendContext } from "./wsContext";
|
||||||
|
import type { Update, WsSend } from "./wsContext";
|
||||||
|
|
||||||
|
export type { Update };
|
||||||
|
|
||||||
export type WsFeedMessage = {
|
export type WsFeedMessage = {
|
||||||
topic: string;
|
topic: string;
|
||||||
@ -24,55 +32,205 @@ export type WsFeedMessage = {
|
|||||||
id: string;
|
id: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Update = {
|
|
||||||
topic: string;
|
|
||||||
payload: unknown;
|
|
||||||
retain: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type WsState = {
|
type WsState = {
|
||||||
[topic: string]: unknown;
|
[topic: string]: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
type useValueReturn = [WsState, (update: Update) => void];
|
// ---------------------------------------------------------------------------
|
||||||
|
// External store for WebSocket state using useSyncExternalStore
|
||||||
|
//
|
||||||
|
// Per-topic subscriptions ensure only subscribers of a changed topic are
|
||||||
|
// notified, avoiding O(messages × total_subscribers) snapshot checks.
|
||||||
|
//
|
||||||
|
// Updates are batched via requestAnimationFrame so a burst of WS messages
|
||||||
|
// (e.g. on initial connect) results in a single React render pass.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type Listener = () => void;
|
||||||
|
|
||||||
|
const wsState: WsState = {};
|
||||||
|
const wsTopicListeners = new Map<string, Set<Listener>>();
|
||||||
|
|
||||||
|
// --- rAF drain + flush ---
|
||||||
|
//
|
||||||
|
// The onmessage handler in WsProvider pushes raw event.data strings into
|
||||||
|
// rawMessageBuffer (sub-microsecond cost). A single requestAnimationFrame
|
||||||
|
// callback drains the buffer, JSON-parses each message, applies state updates,
|
||||||
|
// and notifies React subscribers — all in one burst per frame. This gives
|
||||||
|
// React uninterrupted CPU time between frames.
|
||||||
|
|
||||||
|
const rawMessageBuffer: string[] = [];
|
||||||
|
let pendingUpdates: Record<string, unknown> | null = null;
|
||||||
|
let pendingFeedMessages: WsFeedMessage[] = [];
|
||||||
|
let flushScheduled = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset all module-level state. Called on WsProvider unmount to prevent
|
||||||
|
* stale data from leaking across mount/unmount cycles (e.g. HMR, logout).
|
||||||
|
*/
|
||||||
|
export function resetWsStore() {
|
||||||
|
for (const key of Object.keys(wsState)) {
|
||||||
|
delete wsState[key];
|
||||||
|
}
|
||||||
|
wsTopicListeners.clear();
|
||||||
|
rawMessageBuffer.length = 0;
|
||||||
|
pendingUpdates = null;
|
||||||
|
pendingFeedMessages = [];
|
||||||
|
flushScheduled = false;
|
||||||
|
lastCameraActivityPayload = null;
|
||||||
|
wsMessageSubscribers.clear();
|
||||||
|
wsMessageIdCounter = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bufferRawMessage(data: string) {
|
||||||
|
rawMessageBuffer.push(data);
|
||||||
|
if (!flushScheduled) {
|
||||||
|
flushScheduled = true;
|
||||||
|
requestAnimationFrame(drainAndFlush);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drainAndFlush() {
|
||||||
|
flushScheduled = false;
|
||||||
|
|
||||||
|
// 1. Drain raw buffer → ingest messages
|
||||||
|
if (rawMessageBuffer.length > 0) {
|
||||||
|
const batch = rawMessageBuffer.splice(0);
|
||||||
|
for (const raw of batch) {
|
||||||
|
const data: Update = JSON.parse(raw);
|
||||||
|
if (data) {
|
||||||
|
ingestMessage(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Flush state updates to React
|
||||||
|
if (pendingUpdates) {
|
||||||
|
const updates = pendingUpdates;
|
||||||
|
pendingUpdates = null;
|
||||||
|
|
||||||
|
for (const [topic, newVal] of Object.entries(updates)) {
|
||||||
|
const oldVal = wsState[topic];
|
||||||
|
// Fast path: === for primitives ("ON"/"OFF", numbers).
|
||||||
|
// Fall back to isEqual for objects/arrays.
|
||||||
|
const unchanged =
|
||||||
|
oldVal === newVal ||
|
||||||
|
(typeof newVal === "object" &&
|
||||||
|
newVal !== null &&
|
||||||
|
isEqual(oldVal, newVal));
|
||||||
|
if (!unchanged) {
|
||||||
|
wsState[topic] = newVal;
|
||||||
|
// Snapshot the Set — a listener may trigger unmount that modifies it.
|
||||||
|
const listeners = wsTopicListeners.get(topic);
|
||||||
|
if (listeners) {
|
||||||
|
for (const l of Array.from(listeners)) l();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Deliver feed messages
|
||||||
|
if (pendingFeedMessages.length > 0 && wsMessageSubscribers.size > 0) {
|
||||||
|
const msgs = pendingFeedMessages;
|
||||||
|
pendingFeedMessages = [];
|
||||||
|
for (const msg of msgs) {
|
||||||
|
wsMessageSubscribers.forEach((cb) => cb(msg));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pendingFeedMessages = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ingestMessage(data: Update) {
|
||||||
|
if (!pendingUpdates) pendingUpdates = {};
|
||||||
|
pendingUpdates[data.topic] = data.payload;
|
||||||
|
|
||||||
|
if (data.topic === "camera_activity") {
|
||||||
|
expandCameraActivity(data.payload as string, pendingUpdates);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wsMessageSubscribers.size > 0) {
|
||||||
|
pendingFeedMessages.push({
|
||||||
|
topic: data.topic,
|
||||||
|
payload: data.payload,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
id: String(wsMessageIdCounter++),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Subscriptions ---
|
||||||
|
|
||||||
|
export function subscribeWsTopic(
|
||||||
|
topic: string,
|
||||||
|
listener: Listener,
|
||||||
|
): () => void {
|
||||||
|
let set = wsTopicListeners.get(topic);
|
||||||
|
if (!set) {
|
||||||
|
set = new Set();
|
||||||
|
wsTopicListeners.set(topic, set);
|
||||||
|
}
|
||||||
|
set.add(listener);
|
||||||
|
return () => {
|
||||||
|
set!.delete(listener);
|
||||||
|
if (set!.size === 0) wsTopicListeners.delete(topic);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWsTopicValue(topic: string): unknown {
|
||||||
|
return wsState[topic];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Feed message subscribers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const wsMessageSubscribers = new Set<(msg: WsFeedMessage) => void>();
|
const wsMessageSubscribers = new Set<(msg: WsFeedMessage) => void>();
|
||||||
let wsMessageIdCounter = 0;
|
let wsMessageIdCounter = 0;
|
||||||
|
|
||||||
function useValue(): useValueReturn {
|
// ---------------------------------------------------------------------------
|
||||||
const wsUrl = `${baseUrl.replace(/^http/, "ws")}ws`;
|
// Camera activity expansion
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// main state
|
// Cache the last raw camera_activity JSON string so we can skip JSON.parse
|
||||||
|
// and the entire expansion when nothing has changed. This avoids creating
|
||||||
|
// fresh objects (which defeat Object.is and force expensive isEqual deep
|
||||||
|
// traversals) on every flush — critical with many cameras.
|
||||||
|
let lastCameraActivityPayload: string | null = null;
|
||||||
|
|
||||||
const [wsState, setWsState] = useState<WsState>({});
|
function expandCameraActivity(
|
||||||
|
payload: string,
|
||||||
useEffect(() => {
|
updates: Record<string, unknown>,
|
||||||
const activityValue: string = wsState["camera_activity"] as string;
|
) {
|
||||||
|
// Fast path: if the raw JSON string is identical, nothing changed.
|
||||||
if (!activityValue) {
|
if (payload === lastCameraActivityPayload) {
|
||||||
|
// Remove camera_activity from pending updates so flushPendingUpdates
|
||||||
|
// doesn't run isEqual on a freshly-parsed object that's identical.
|
||||||
|
delete updates["camera_activity"];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
lastCameraActivityPayload = payload;
|
||||||
|
|
||||||
let cameraActivity: { [key: string]: Partial<FrigateCameraState> };
|
let activity: { [key: string]: Partial<FrigateCameraState> };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
cameraActivity = JSON.parse(activityValue);
|
activity = JSON.parse(payload);
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(cameraActivity).length === 0) {
|
// Remove the root topic — no component reads the monolithic object.
|
||||||
return;
|
// Only per-camera subtopics and per-setting primitives are stored.
|
||||||
}
|
delete updates["camera_activity"];
|
||||||
|
|
||||||
const cameraStates: WsState = {};
|
if (Object.keys(activity).length === 0) return;
|
||||||
|
|
||||||
|
for (const [name, state] of Object.entries(activity)) {
|
||||||
|
// Notify per-camera subtopic specifically
|
||||||
|
updates[`camera_activity/${name}`] = state;
|
||||||
|
|
||||||
Object.entries(cameraActivity).forEach(([name, state]) => {
|
|
||||||
const cameraConfig = state?.config;
|
const cameraConfig = state?.config;
|
||||||
|
if (!cameraConfig) continue;
|
||||||
if (!cameraConfig) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
record,
|
record,
|
||||||
@ -89,105 +247,62 @@ function useValue(): useValueReturn {
|
|||||||
object_descriptions,
|
object_descriptions,
|
||||||
review_descriptions,
|
review_descriptions,
|
||||||
} = cameraConfig;
|
} = cameraConfig;
|
||||||
cameraStates[`${name}/recordings/state`] = record ? "ON" : "OFF";
|
|
||||||
cameraStates[`${name}/enabled/state`] = enabled ? "ON" : "OFF";
|
|
||||||
cameraStates[`${name}/detect/state`] = detect ? "ON" : "OFF";
|
|
||||||
cameraStates[`${name}/snapshots/state`] = snapshots ? "ON" : "OFF";
|
|
||||||
cameraStates[`${name}/audio/state`] = audio ? "ON" : "OFF";
|
|
||||||
cameraStates[`${name}/audio_transcription/state`] = audio_transcription
|
|
||||||
? "ON"
|
|
||||||
: "OFF";
|
|
||||||
cameraStates[`${name}/notifications/state`] = notifications
|
|
||||||
? "ON"
|
|
||||||
: "OFF";
|
|
||||||
cameraStates[`${name}/notifications/suspended`] =
|
|
||||||
notifications_suspended || 0;
|
|
||||||
cameraStates[`${name}/ptz_autotracker/state`] = autotracking
|
|
||||||
? "ON"
|
|
||||||
: "OFF";
|
|
||||||
cameraStates[`${name}/review_alerts/state`] = alerts ? "ON" : "OFF";
|
|
||||||
cameraStates[`${name}/review_detections/state`] = detections
|
|
||||||
? "ON"
|
|
||||||
: "OFF";
|
|
||||||
cameraStates[`${name}/object_descriptions/state`] = object_descriptions
|
|
||||||
? "ON"
|
|
||||||
: "OFF";
|
|
||||||
cameraStates[`${name}/review_descriptions/state`] = review_descriptions
|
|
||||||
? "ON"
|
|
||||||
: "OFF";
|
|
||||||
});
|
|
||||||
|
|
||||||
setWsState((prevState) => ({
|
updates[`${name}/recordings/state`] = record ? "ON" : "OFF";
|
||||||
...prevState,
|
updates[`${name}/enabled/state`] = enabled ? "ON" : "OFF";
|
||||||
...cameraStates,
|
updates[`${name}/detect/state`] = detect ? "ON" : "OFF";
|
||||||
}));
|
updates[`${name}/snapshots/state`] = snapshots ? "ON" : "OFF";
|
||||||
|
updates[`${name}/audio/state`] = audio ? "ON" : "OFF";
|
||||||
// we only want this to run initially when the config is loaded
|
updates[`${name}/audio_transcription/state`] = audio_transcription
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
? "ON"
|
||||||
}, [wsState["camera_activity"]]);
|
: "OFF";
|
||||||
|
updates[`${name}/notifications/state`] = notifications ? "ON" : "OFF";
|
||||||
// ws handler
|
updates[`${name}/notifications/suspended`] = notifications_suspended || 0;
|
||||||
const { sendJsonMessage, readyState } = useWebSocket(wsUrl, {
|
updates[`${name}/ptz_autotracker/state`] = autotracking ? "ON" : "OFF";
|
||||||
onMessage: (event) => {
|
updates[`${name}/review_alerts/state`] = alerts ? "ON" : "OFF";
|
||||||
const data: Update = JSON.parse(event.data);
|
updates[`${name}/review_detections/state`] = detections ? "ON" : "OFF";
|
||||||
|
updates[`${name}/object_descriptions/state`] = object_descriptions
|
||||||
if (data) {
|
? "ON"
|
||||||
setWsState((prevState) => ({
|
: "OFF";
|
||||||
...prevState,
|
updates[`${name}/review_descriptions/state`] = review_descriptions
|
||||||
[data.topic]: data.payload,
|
? "ON"
|
||||||
}));
|
: "OFF";
|
||||||
|
|
||||||
// Notify feed subscribers
|
|
||||||
if (wsMessageSubscribers.size > 0) {
|
|
||||||
const feedMsg: WsFeedMessage = {
|
|
||||||
topic: data.topic,
|
|
||||||
payload: data.payload,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
id: String(wsMessageIdCounter++),
|
|
||||||
};
|
|
||||||
wsMessageSubscribers.forEach((cb) => cb(feedMsg));
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
},
|
|
||||||
onOpen: () => {
|
|
||||||
sendJsonMessage({
|
|
||||||
topic: "onConnect",
|
|
||||||
message: "",
|
|
||||||
retain: false,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onClose: () => {},
|
|
||||||
shouldReconnect: () => true,
|
|
||||||
retryOnError: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const setState = useCallback(
|
|
||||||
(message: Update) => {
|
|
||||||
if (readyState === ReadyState.OPEN) {
|
|
||||||
sendJsonMessage({
|
|
||||||
topic: message.topic,
|
|
||||||
payload: message.payload,
|
|
||||||
retain: message.retain,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[readyState, sendJsonMessage],
|
|
||||||
);
|
|
||||||
|
|
||||||
return [wsState, setState];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const {
|
// ---------------------------------------------------------------------------
|
||||||
Provider: WsProvider,
|
// Hooks
|
||||||
useTrackedState: useWsState,
|
// ---------------------------------------------------------------------------
|
||||||
useUpdate: useWsUpdate,
|
|
||||||
} = createContainer(useValue, { defaultState: {}, concurrentMode: true });
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the send function for publishing WS messages.
|
||||||
|
*/
|
||||||
|
export function useWsUpdate(): WsSend {
|
||||||
|
const send = useContext(WsSendContext);
|
||||||
|
if (!send) {
|
||||||
|
throw new Error("useWsUpdate must be used within WsProvider");
|
||||||
|
}
|
||||||
|
return send;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to a single WS topic with proper bail-out.
|
||||||
|
* Only re-renders when the topic's value changes (Object.is comparison).
|
||||||
|
* Uses useSyncExternalStore — zero useEffect, so no PassiveMask flags
|
||||||
|
* propagate through the fiber tree.
|
||||||
|
*/
|
||||||
export function useWs(watchTopic: string, publishTopic: string) {
|
export function useWs(watchTopic: string, publishTopic: string) {
|
||||||
const state = useWsState();
|
const payload = useSyncExternalStore(
|
||||||
|
useCallback(
|
||||||
|
(listener: Listener) => subscribeWsTopic(watchTopic, listener),
|
||||||
|
[watchTopic],
|
||||||
|
),
|
||||||
|
useCallback(() => wsState[watchTopic], [watchTopic]),
|
||||||
|
);
|
||||||
|
|
||||||
const sendJsonMessage = useWsUpdate();
|
const sendJsonMessage = useWsUpdate();
|
||||||
|
|
||||||
const value = { payload: state[watchTopic] || null };
|
const value = { payload: payload ?? null };
|
||||||
|
|
||||||
const send = useCallback(
|
const send = useCallback(
|
||||||
(payload: unknown, retain = false) => {
|
(payload: unknown, retain = false) => {
|
||||||
@ -203,6 +318,10 @@ export function useWs(watchTopic: string, publishTopic: string) {
|
|||||||
return { value, send };
|
return { value, send };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Convenience hooks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export function useEnabledState(camera: string): {
|
export function useEnabledState(camera: string): {
|
||||||
payload: ToggleableSetting;
|
payload: ToggleableSetting;
|
||||||
send: (payload: ToggleableSetting, retain?: boolean) => void;
|
send: (payload: ToggleableSetting, retain?: boolean) => void;
|
||||||
@ -413,28 +532,42 @@ export function useFrigateEvents(): { payload: FrigateEvent } {
|
|||||||
const {
|
const {
|
||||||
value: { payload },
|
value: { payload },
|
||||||
} = useWs("events", "");
|
} = useWs("events", "");
|
||||||
return { payload: JSON.parse(payload as string) };
|
const parsed = useMemo(
|
||||||
|
() => (payload ? JSON.parse(payload as string) : undefined),
|
||||||
|
[payload],
|
||||||
|
);
|
||||||
|
return { payload: parsed };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAudioDetections(): { payload: FrigateAudioDetections } {
|
export function useAudioDetections(): { payload: FrigateAudioDetections } {
|
||||||
const {
|
const {
|
||||||
value: { payload },
|
value: { payload },
|
||||||
} = useWs("audio_detections", "");
|
} = useWs("audio_detections", "");
|
||||||
return { payload: JSON.parse(payload as string) };
|
const parsed = useMemo(
|
||||||
|
() => (payload ? JSON.parse(payload as string) : undefined),
|
||||||
|
[payload],
|
||||||
|
);
|
||||||
|
return { payload: parsed };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useFrigateReviews(): FrigateReview {
|
export function useFrigateReviews(): FrigateReview {
|
||||||
const {
|
const {
|
||||||
value: { payload },
|
value: { payload },
|
||||||
} = useWs("reviews", "");
|
} = useWs("reviews", "");
|
||||||
return useDeepMemo(JSON.parse(payload as string));
|
return useMemo(
|
||||||
|
() => (payload ? JSON.parse(payload as string) : undefined),
|
||||||
|
[payload],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useFrigateStats(): FrigateStats {
|
export function useFrigateStats(): FrigateStats {
|
||||||
const {
|
const {
|
||||||
value: { payload },
|
value: { payload },
|
||||||
} = useWs("stats", "");
|
} = useWs("stats", "");
|
||||||
return useDeepMemo(JSON.parse(payload as string));
|
return useMemo(
|
||||||
|
() => (payload ? JSON.parse(payload as string) : undefined),
|
||||||
|
[payload],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useInitialCameraState(
|
export function useInitialCameraState(
|
||||||
@ -446,32 +579,32 @@ export function useInitialCameraState(
|
|||||||
const {
|
const {
|
||||||
value: { payload },
|
value: { payload },
|
||||||
send: sendCommand,
|
send: sendCommand,
|
||||||
} = useWs("camera_activity", "onConnect");
|
} = useWs(`camera_activity/${camera}`, "onConnect");
|
||||||
|
|
||||||
const data = useDeepMemo(JSON.parse(payload as string));
|
// camera_activity sub-topic payload is already parsed by expandCameraActivity
|
||||||
|
const data = payload as FrigateCameraState | undefined;
|
||||||
|
|
||||||
|
// onConnect is sent once in WsProvider.onopen — no need to re-request on
|
||||||
|
// every component mount. Components read cached wsState immediately via
|
||||||
|
// useSyncExternalStore. Only re-request when the user tabs back in.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let listener = undefined;
|
|
||||||
if (revalidateOnFocus) {
|
|
||||||
sendCommand("onConnect");
|
sendCommand("onConnect");
|
||||||
listener = () => {
|
if (!revalidateOnFocus) return;
|
||||||
if (document.visibilityState == "visible") {
|
|
||||||
|
const listener = () => {
|
||||||
|
if (document.visibilityState === "visible") {
|
||||||
sendCommand("onConnect");
|
sendCommand("onConnect");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
addEventListener("visibilitychange", listener);
|
addEventListener("visibilitychange", listener);
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (listener) {
|
|
||||||
removeEventListener("visibilitychange", listener);
|
removeEventListener("visibilitychange", listener);
|
||||||
}
|
|
||||||
};
|
};
|
||||||
// only refresh when onRefresh value changes
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [revalidateOnFocus]);
|
}, [revalidateOnFocus]);
|
||||||
|
|
||||||
return { payload: data ? data[camera] : undefined };
|
return { payload: data as FrigateCameraState };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useModelState(
|
export function useModelState(
|
||||||
@ -483,7 +616,10 @@ export function useModelState(
|
|||||||
send: sendCommand,
|
send: sendCommand,
|
||||||
} = useWs("model_state", "modelState");
|
} = useWs("model_state", "modelState");
|
||||||
|
|
||||||
const data = useDeepMemo(JSON.parse(payload as string));
|
const data = useMemo(
|
||||||
|
() => (payload ? JSON.parse(payload as string) : undefined),
|
||||||
|
[payload],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let listener = undefined;
|
let listener = undefined;
|
||||||
@ -519,7 +655,10 @@ export function useEmbeddingsReindexProgress(
|
|||||||
send: sendCommand,
|
send: sendCommand,
|
||||||
} = useWs("embeddings_reindex_progress", "embeddingsReindexProgress");
|
} = useWs("embeddings_reindex_progress", "embeddingsReindexProgress");
|
||||||
|
|
||||||
const data = useDeepMemo(JSON.parse(payload as string));
|
const data = useMemo(
|
||||||
|
() => (payload ? JSON.parse(payload as string) : undefined),
|
||||||
|
[payload],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let listener = undefined;
|
let listener = undefined;
|
||||||
@ -553,8 +692,9 @@ export function useAudioTranscriptionProcessState(
|
|||||||
send: sendCommand,
|
send: sendCommand,
|
||||||
} = useWs("audio_transcription_state", "audioTranscriptionState");
|
} = useWs("audio_transcription_state", "audioTranscriptionState");
|
||||||
|
|
||||||
const data = useDeepMemo(
|
const data = useMemo(
|
||||||
payload ? (JSON.parse(payload as string) as string) : "idle",
|
() => (payload ? (JSON.parse(payload as string) as string) : "idle"),
|
||||||
|
[payload],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -587,7 +727,10 @@ export function useBirdseyeLayout(revalidateOnFocus: boolean = true): {
|
|||||||
send: sendCommand,
|
send: sendCommand,
|
||||||
} = useWs("birdseye_layout", "birdseyeLayout");
|
} = useWs("birdseye_layout", "birdseyeLayout");
|
||||||
|
|
||||||
const data = useDeepMemo(JSON.parse(payload as string));
|
const data = useMemo(
|
||||||
|
() => (payload ? JSON.parse(payload as string) : undefined),
|
||||||
|
[payload],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let listener = undefined;
|
let listener = undefined;
|
||||||
@ -684,10 +827,14 @@ export function useTrackedObjectUpdate(): {
|
|||||||
const {
|
const {
|
||||||
value: { payload },
|
value: { payload },
|
||||||
} = useWs("tracked_object_update", "");
|
} = useWs("tracked_object_update", "");
|
||||||
const parsed = payload
|
const parsed = useMemo(
|
||||||
|
() =>
|
||||||
|
payload
|
||||||
? JSON.parse(payload as string)
|
? JSON.parse(payload as string)
|
||||||
: { type: "", id: "", camera: "" };
|
: { type: "", id: "", camera: "" },
|
||||||
return { payload: useDeepMemo(parsed) };
|
[payload],
|
||||||
|
);
|
||||||
|
return { payload: parsed };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useNotifications(camera: string): {
|
export function useNotifications(camera: string): {
|
||||||
@ -730,10 +877,14 @@ export function useTriggers(): { payload: TriggerStatus } {
|
|||||||
const {
|
const {
|
||||||
value: { payload },
|
value: { payload },
|
||||||
} = useWs("triggers", "");
|
} = useWs("triggers", "");
|
||||||
const parsed = payload
|
const parsed = useMemo(
|
||||||
|
() =>
|
||||||
|
payload
|
||||||
? JSON.parse(payload as string)
|
? JSON.parse(payload as string)
|
||||||
: { name: "", camera: "", event_id: "", type: "", score: 0 };
|
: { name: "", camera: "", event_id: "", type: "", score: 0 },
|
||||||
return { payload: useDeepMemo(parsed) };
|
[payload],
|
||||||
|
);
|
||||||
|
return { payload: parsed };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useJobStatus(
|
export function useJobStatus(
|
||||||
@ -745,8 +896,9 @@ export function useJobStatus(
|
|||||||
send: sendCommand,
|
send: sendCommand,
|
||||||
} = useWs("job_state", "jobState");
|
} = useWs("job_state", "jobState");
|
||||||
|
|
||||||
const jobData = useDeepMemo(
|
const jobData = useMemo(
|
||||||
payload && typeof payload === "string" ? JSON.parse(payload) : {},
|
() => (payload && typeof payload === "string" ? JSON.parse(payload) : {}),
|
||||||
|
[payload],
|
||||||
);
|
);
|
||||||
const currentJob = jobData[jobType] || null;
|
const currentJob = jobData[jobType] || null;
|
||||||
|
|
||||||
11
web/src/api/wsContext.ts
Normal file
11
web/src/api/wsContext.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { createContext } from "react";
|
||||||
|
|
||||||
|
export type Update = {
|
||||||
|
topic: string;
|
||||||
|
payload: unknown;
|
||||||
|
retain: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WsSend = (update: Update) => void;
|
||||||
|
|
||||||
|
export const WsSendContext = createContext<WsSend | null>(null);
|
||||||
Loading…
Reference in New Issue
Block a user