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.
2026-03-09 16:26:31 +03:00
|
|
|
import {
|
|
|
|
|
useCallback,
|
|
|
|
|
useContext,
|
|
|
|
|
useEffect,
|
|
|
|
|
useMemo,
|
|
|
|
|
useRef,
|
|
|
|
|
useSyncExternalStore,
|
|
|
|
|
} from "react";
|
2024-04-30 16:09:50 +03:00
|
|
|
import {
|
2024-10-10 22:28:43 +03:00
|
|
|
EmbeddingsReindexProgressType,
|
2024-04-30 16:09:50 +03:00
|
|
|
FrigateCameraState,
|
|
|
|
|
FrigateEvent,
|
|
|
|
|
FrigateReview,
|
2024-10-07 23:30:45 +03:00
|
|
|
ModelState,
|
2024-04-30 16:09:50 +03:00
|
|
|
ToggleableSetting,
|
2025-05-27 18:26:00 +03:00
|
|
|
TrackedObjectUpdateReturnType,
|
2025-07-07 17:03:57 +03:00
|
|
|
TriggerStatus,
|
2025-08-25 21:40:21 +03:00
|
|
|
FrigateAudioDetections,
|
2026-01-06 18:20:19 +03:00
|
|
|
Job,
|
2024-04-30 16:09:50 +03:00
|
|
|
} from "@/types/ws";
|
2024-02-22 05:27:02 +03:00
|
|
|
import { FrigateStats } from "@/types/stats";
|
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.
2026-03-09 16:26:31 +03:00
|
|
|
import { isEqual } from "lodash";
|
|
|
|
|
import { WsSendContext } from "./wsContext";
|
|
|
|
|
import type { Update, WsSend } from "./wsContext";
|
|
|
|
|
|
|
|
|
|
export type { Update };
|
2023-12-08 16:33:22 +03:00
|
|
|
|
2026-03-04 19:07:34 +03:00
|
|
|
export type WsFeedMessage = {
|
|
|
|
|
topic: string;
|
|
|
|
|
payload: unknown;
|
|
|
|
|
timestamp: number;
|
|
|
|
|
id: string;
|
|
|
|
|
};
|
|
|
|
|
|
2024-02-27 19:05:28 +03:00
|
|
|
type WsState = {
|
2024-02-29 01:23:56 +03:00
|
|
|
[topic: string]: unknown;
|
2023-12-08 16:33:22 +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.
2026-03-09 16:26:31 +03:00
|
|
|
// External store for WebSocket state using useSyncExternalStore
|
|
|
|
|
type Listener = () => void;
|
|
|
|
|
|
|
|
|
|
const wsState: WsState = {};
|
|
|
|
|
const wsTopicListeners = new Map<string, Set<Listener>>();
|
|
|
|
|
|
2026-03-09 16:56:56 +03:00
|
|
|
// Reset all module-level state. Called on WsProvider unmount to prevent
|
|
|
|
|
// stale data from leaking across mount/unmount cycles (e.g. HMR, logout)
|
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.
2026-03-09 16:26:31 +03:00
|
|
|
export function resetWsStore() {
|
|
|
|
|
for (const key of Object.keys(wsState)) {
|
|
|
|
|
delete wsState[key];
|
|
|
|
|
}
|
|
|
|
|
wsTopicListeners.clear();
|
|
|
|
|
lastCameraActivityPayload = null;
|
|
|
|
|
wsMessageSubscribers.clear();
|
|
|
|
|
wsMessageIdCounter = 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 16:56:56 +03:00
|
|
|
// Parse and apply a raw WS message synchronously.
|
|
|
|
|
// Called directly from WsProvider's onmessage handler.
|
2026-03-09 16:54:54 +03:00
|
|
|
export function processWsMessage(raw: string) {
|
|
|
|
|
const data: Update = JSON.parse(raw);
|
|
|
|
|
if (!data) return;
|
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.
2026-03-09 16:26:31 +03:00
|
|
|
|
2026-03-09 16:54:54 +03:00
|
|
|
const { topic, payload } = data;
|
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.
2026-03-09 16:26:31 +03:00
|
|
|
|
2026-03-09 16:54:54 +03:00
|
|
|
if (topic === "camera_activity") {
|
|
|
|
|
applyCameraActivity(payload as string);
|
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.
2026-03-09 16:26:31 +03:00
|
|
|
} else {
|
2026-03-09 16:54:54 +03:00
|
|
|
applyTopicUpdate(topic, payload);
|
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.
2026-03-09 16:26:31 +03:00
|
|
|
}
|
2026-03-04 19:07:34 +03:00
|
|
|
|
2026-03-09 16:54:54 +03:00
|
|
|
if (wsMessageSubscribers.size > 0) {
|
|
|
|
|
wsMessageSubscribers.forEach((cb) =>
|
|
|
|
|
cb({
|
|
|
|
|
topic,
|
|
|
|
|
payload,
|
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
id: String(wsMessageIdCounter++),
|
|
|
|
|
}),
|
|
|
|
|
);
|
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.
2026-03-09 16:26:31 +03:00
|
|
|
}
|
2026-03-09 16:54:54 +03:00
|
|
|
}
|
2026-03-04 19:07:34 +03:00
|
|
|
|
2026-03-09 16:54:54 +03:00
|
|
|
function applyTopicUpdate(topic: string, newVal: unknown) {
|
|
|
|
|
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) return;
|
|
|
|
|
|
|
|
|
|
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();
|
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.
2026-03-09 16:26:31 +03:00
|
|
|
}
|
|
|
|
|
}
|
2024-02-27 19:05:28 +03:00
|
|
|
|
2026-03-09 16:56:56 +03:00
|
|
|
// Subscriptions
|
2024-02-27 19:05:28 +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.
2026-03-09 16:26:31 +03:00
|
|
|
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);
|
|
|
|
|
};
|
|
|
|
|
}
|
2024-02-27 19:05:28 +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.
2026-03-09 16:26:31 +03:00
|
|
|
export function getWsTopicValue(topic: string): unknown {
|
|
|
|
|
return wsState[topic];
|
2023-12-08 16:33:22 +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.
2026-03-09 16:26:31 +03:00
|
|
|
// Feed message subscribers
|
|
|
|
|
const wsMessageSubscribers = new Set<(msg: WsFeedMessage) => void>();
|
|
|
|
|
let wsMessageIdCounter = 0;
|
|
|
|
|
|
|
|
|
|
// Camera activity expansion
|
2026-03-09 16:56:56 +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.
2026-03-09 16:26:31 +03:00
|
|
|
// 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;
|
|
|
|
|
|
2026-03-09 16:54:54 +03:00
|
|
|
function applyCameraActivity(payload: string) {
|
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.
2026-03-09 16:26:31 +03:00
|
|
|
// Fast path: if the raw JSON string is identical, nothing changed.
|
2026-03-09 16:54:54 +03:00
|
|
|
if (payload === lastCameraActivityPayload) return;
|
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.
2026-03-09 16:26:31 +03:00
|
|
|
lastCameraActivityPayload = payload;
|
|
|
|
|
|
|
|
|
|
let activity: { [key: string]: Partial<FrigateCameraState> };
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
activity = JSON.parse(payload);
|
|
|
|
|
} catch {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (Object.keys(activity).length === 0) return;
|
|
|
|
|
|
|
|
|
|
for (const [name, state] of Object.entries(activity)) {
|
2026-03-09 16:54:54 +03:00
|
|
|
applyTopicUpdate(`camera_activity/${name}`, state);
|
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.
2026-03-09 16:26:31 +03:00
|
|
|
|
|
|
|
|
const cameraConfig = state?.config;
|
|
|
|
|
if (!cameraConfig) continue;
|
|
|
|
|
|
|
|
|
|
const {
|
|
|
|
|
record,
|
|
|
|
|
detect,
|
|
|
|
|
enabled,
|
|
|
|
|
snapshots,
|
|
|
|
|
audio,
|
|
|
|
|
audio_transcription,
|
|
|
|
|
notifications,
|
|
|
|
|
notifications_suspended,
|
|
|
|
|
autotracking,
|
|
|
|
|
alerts,
|
|
|
|
|
detections,
|
|
|
|
|
object_descriptions,
|
|
|
|
|
review_descriptions,
|
|
|
|
|
} = cameraConfig;
|
|
|
|
|
|
2026-03-09 16:54:54 +03:00
|
|
|
applyTopicUpdate(`${name}/recordings/state`, record ? "ON" : "OFF");
|
|
|
|
|
applyTopicUpdate(`${name}/enabled/state`, enabled ? "ON" : "OFF");
|
|
|
|
|
applyTopicUpdate(`${name}/detect/state`, detect ? "ON" : "OFF");
|
|
|
|
|
applyTopicUpdate(`${name}/snapshots/state`, snapshots ? "ON" : "OFF");
|
|
|
|
|
applyTopicUpdate(`${name}/audio/state`, audio ? "ON" : "OFF");
|
|
|
|
|
applyTopicUpdate(
|
|
|
|
|
`${name}/audio_transcription/state`,
|
|
|
|
|
audio_transcription ? "ON" : "OFF",
|
|
|
|
|
);
|
|
|
|
|
applyTopicUpdate(
|
|
|
|
|
`${name}/notifications/state`,
|
|
|
|
|
notifications ? "ON" : "OFF",
|
|
|
|
|
);
|
|
|
|
|
applyTopicUpdate(
|
|
|
|
|
`${name}/notifications/suspended`,
|
|
|
|
|
notifications_suspended || 0,
|
|
|
|
|
);
|
|
|
|
|
applyTopicUpdate(
|
|
|
|
|
`${name}/ptz_autotracker/state`,
|
|
|
|
|
autotracking ? "ON" : "OFF",
|
|
|
|
|
);
|
|
|
|
|
applyTopicUpdate(`${name}/review_alerts/state`, alerts ? "ON" : "OFF");
|
|
|
|
|
applyTopicUpdate(
|
|
|
|
|
`${name}/review_detections/state`,
|
|
|
|
|
detections ? "ON" : "OFF",
|
|
|
|
|
);
|
|
|
|
|
applyTopicUpdate(
|
|
|
|
|
`${name}/object_descriptions/state`,
|
|
|
|
|
object_descriptions ? "ON" : "OFF",
|
|
|
|
|
);
|
|
|
|
|
applyTopicUpdate(
|
|
|
|
|
`${name}/review_descriptions/state`,
|
|
|
|
|
review_descriptions ? "ON" : "OFF",
|
|
|
|
|
);
|
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.
2026-03-09 16:26:31 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Hooks
|
|
|
|
|
export function useWsUpdate(): WsSend {
|
|
|
|
|
const send = useContext(WsSendContext);
|
|
|
|
|
if (!send) {
|
|
|
|
|
throw new Error("useWsUpdate must be used within WsProvider");
|
|
|
|
|
}
|
|
|
|
|
return send;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 17:24:53 +03:00
|
|
|
// 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.
|
2023-12-08 16:33:22 +03:00
|
|
|
export function useWs(watchTopic: string, publishTopic: string) {
|
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.
2026-03-09 16:26:31 +03:00
|
|
|
const payload = useSyncExternalStore(
|
|
|
|
|
useCallback(
|
|
|
|
|
(listener: Listener) => subscribeWsTopic(watchTopic, listener),
|
|
|
|
|
[watchTopic],
|
|
|
|
|
),
|
|
|
|
|
useCallback(() => wsState[watchTopic], [watchTopic]),
|
|
|
|
|
);
|
|
|
|
|
|
2024-02-27 19:05:28 +03:00
|
|
|
const sendJsonMessage = useWsUpdate();
|
2023-12-08 16:33:22 +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.
2026-03-09 16:26:31 +03:00
|
|
|
const value = { payload: payload ?? null };
|
2023-12-08 16:33:22 +03:00
|
|
|
|
|
|
|
|
const send = useCallback(
|
2024-02-29 01:23:56 +03:00
|
|
|
(payload: unknown, retain = false) => {
|
2024-02-27 19:05:28 +03:00
|
|
|
sendJsonMessage({
|
|
|
|
|
topic: publishTopic || watchTopic,
|
|
|
|
|
payload,
|
|
|
|
|
retain,
|
|
|
|
|
});
|
2023-12-08 16:33:22 +03:00
|
|
|
},
|
2024-02-29 01:23:56 +03:00
|
|
|
[sendJsonMessage, watchTopic, publishTopic],
|
2023-12-08 16:33:22 +03:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return { value, send };
|
|
|
|
|
}
|
|
|
|
|
|
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.
2026-03-09 16:26:31 +03:00
|
|
|
// Convenience hooks
|
|
|
|
|
|
2025-03-03 18:30:52 +03:00
|
|
|
export function useEnabledState(camera: string): {
|
|
|
|
|
payload: ToggleableSetting;
|
|
|
|
|
send: (payload: ToggleableSetting, retain?: boolean) => void;
|
|
|
|
|
} {
|
|
|
|
|
const {
|
|
|
|
|
value: { payload },
|
|
|
|
|
send,
|
|
|
|
|
} = useWs(`${camera}/enabled/state`, `${camera}/enabled/set`);
|
2025-03-20 19:20:44 +03:00
|
|
|
return { payload: payload as ToggleableSetting, send };
|
2025-03-03 18:30:52 +03:00
|
|
|
}
|
|
|
|
|
|
2023-12-29 16:08:00 +03:00
|
|
|
export function useDetectState(camera: string): {
|
2024-02-10 15:30:53 +03:00
|
|
|
payload: ToggleableSetting;
|
|
|
|
|
send: (payload: ToggleableSetting, retain?: boolean) => void;
|
2023-12-29 16:08:00 +03:00
|
|
|
} {
|
2023-12-08 16:33:22 +03:00
|
|
|
const {
|
|
|
|
|
value: { payload },
|
|
|
|
|
send,
|
|
|
|
|
} = useWs(`${camera}/detect/state`, `${camera}/detect/set`);
|
2024-02-29 01:23:56 +03:00
|
|
|
return { payload: payload as ToggleableSetting, send };
|
2023-12-08 16:33:22 +03:00
|
|
|
}
|
|
|
|
|
|
2023-12-29 16:08:00 +03:00
|
|
|
export function useRecordingsState(camera: string): {
|
2024-02-10 15:30:53 +03:00
|
|
|
payload: ToggleableSetting;
|
|
|
|
|
send: (payload: ToggleableSetting, retain?: boolean) => void;
|
2023-12-29 16:08:00 +03:00
|
|
|
} {
|
2023-12-08 16:33:22 +03:00
|
|
|
const {
|
|
|
|
|
value: { payload },
|
|
|
|
|
send,
|
|
|
|
|
} = useWs(`${camera}/recordings/state`, `${camera}/recordings/set`);
|
2024-02-29 01:23:56 +03:00
|
|
|
return { payload: payload as ToggleableSetting, send };
|
2023-12-08 16:33:22 +03:00
|
|
|
}
|
|
|
|
|
|
2023-12-29 16:08:00 +03:00
|
|
|
export function useSnapshotsState(camera: string): {
|
2024-02-10 15:30:53 +03:00
|
|
|
payload: ToggleableSetting;
|
|
|
|
|
send: (payload: ToggleableSetting, retain?: boolean) => void;
|
2023-12-29 16:08:00 +03:00
|
|
|
} {
|
2023-12-08 16:33:22 +03:00
|
|
|
const {
|
|
|
|
|
value: { payload },
|
|
|
|
|
send,
|
|
|
|
|
} = useWs(`${camera}/snapshots/state`, `${camera}/snapshots/set`);
|
2024-02-29 01:23:56 +03:00
|
|
|
return { payload: payload as ToggleableSetting, send };
|
2023-12-08 16:33:22 +03:00
|
|
|
}
|
|
|
|
|
|
2023-12-29 16:08:00 +03:00
|
|
|
export function useAudioState(camera: string): {
|
2024-02-10 15:30:53 +03:00
|
|
|
payload: ToggleableSetting;
|
|
|
|
|
send: (payload: ToggleableSetting, retain?: boolean) => void;
|
2023-12-29 16:08:00 +03:00
|
|
|
} {
|
2023-12-08 16:33:22 +03:00
|
|
|
const {
|
|
|
|
|
value: { payload },
|
|
|
|
|
send,
|
|
|
|
|
} = useWs(`${camera}/audio/state`, `${camera}/audio/set`);
|
2024-02-29 01:23:56 +03:00
|
|
|
return { payload: payload as ToggleableSetting, send };
|
2023-12-08 16:33:22 +03:00
|
|
|
}
|
|
|
|
|
|
2025-05-27 18:26:00 +03:00
|
|
|
export function useAudioTranscriptionState(camera: string): {
|
|
|
|
|
payload: ToggleableSetting;
|
|
|
|
|
send: (payload: ToggleableSetting, retain?: boolean) => void;
|
|
|
|
|
} {
|
|
|
|
|
const {
|
|
|
|
|
value: { payload },
|
|
|
|
|
send,
|
|
|
|
|
} = useWs(
|
|
|
|
|
`${camera}/audio_transcription/state`,
|
|
|
|
|
`${camera}/audio_transcription/set`,
|
|
|
|
|
);
|
|
|
|
|
return { payload: payload as ToggleableSetting, send };
|
|
|
|
|
}
|
|
|
|
|
|
2024-05-16 17:32:39 +03:00
|
|
|
export function useAutotrackingState(camera: string): {
|
|
|
|
|
payload: ToggleableSetting;
|
|
|
|
|
send: (payload: ToggleableSetting, retain?: boolean) => void;
|
|
|
|
|
} {
|
|
|
|
|
const {
|
|
|
|
|
value: { payload },
|
|
|
|
|
send,
|
|
|
|
|
} = useWs(`${camera}/ptz_autotracker/state`, `${camera}/ptz_autotracker/set`);
|
|
|
|
|
return { payload: payload as ToggleableSetting, send };
|
|
|
|
|
}
|
|
|
|
|
|
2025-02-11 17:46:25 +03:00
|
|
|
export function useAlertsState(camera: string): {
|
|
|
|
|
payload: ToggleableSetting;
|
|
|
|
|
send: (payload: ToggleableSetting, retain?: boolean) => void;
|
|
|
|
|
} {
|
|
|
|
|
const {
|
|
|
|
|
value: { payload },
|
|
|
|
|
send,
|
|
|
|
|
} = useWs(`${camera}/review_alerts/state`, `${camera}/review_alerts/set`);
|
|
|
|
|
return { payload: payload as ToggleableSetting, send };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function useDetectionsState(camera: string): {
|
|
|
|
|
payload: ToggleableSetting;
|
|
|
|
|
send: (payload: ToggleableSetting, retain?: boolean) => void;
|
|
|
|
|
} {
|
|
|
|
|
const {
|
|
|
|
|
value: { payload },
|
|
|
|
|
send,
|
|
|
|
|
} = useWs(
|
|
|
|
|
`${camera}/review_detections/state`,
|
|
|
|
|
`${camera}/review_detections/set`,
|
|
|
|
|
);
|
|
|
|
|
return { payload: payload as ToggleableSetting, send };
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-10 16:38:04 +03:00
|
|
|
export function useObjectDescriptionState(camera: string): {
|
2025-07-14 15:58:43 +03:00
|
|
|
payload: ToggleableSetting;
|
|
|
|
|
send: (payload: ToggleableSetting, retain?: boolean) => void;
|
|
|
|
|
} {
|
|
|
|
|
const {
|
|
|
|
|
value: { payload },
|
|
|
|
|
send,
|
2025-08-10 16:38:04 +03:00
|
|
|
} = useWs(
|
|
|
|
|
`${camera}/object_descriptions/state`,
|
|
|
|
|
`${camera}/object_descriptions/set`,
|
|
|
|
|
);
|
|
|
|
|
return { payload: payload as ToggleableSetting, send };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function useReviewDescriptionState(camera: string): {
|
|
|
|
|
payload: ToggleableSetting;
|
|
|
|
|
send: (payload: ToggleableSetting, retain?: boolean) => void;
|
|
|
|
|
} {
|
|
|
|
|
const {
|
|
|
|
|
value: { payload },
|
|
|
|
|
send,
|
|
|
|
|
} = useWs(
|
|
|
|
|
`${camera}/review_descriptions/state`,
|
|
|
|
|
`${camera}/review_descriptions/set`,
|
|
|
|
|
);
|
2025-07-14 15:58:43 +03:00
|
|
|
return { payload: payload as ToggleableSetting, send };
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-28 17:04:43 +03:00
|
|
|
export function useMotionMaskState(
|
|
|
|
|
camera: string,
|
|
|
|
|
maskName: string,
|
|
|
|
|
): {
|
|
|
|
|
payload: ToggleableSetting;
|
|
|
|
|
send: (payload: ToggleableSetting, retain?: boolean) => void;
|
|
|
|
|
} {
|
|
|
|
|
const {
|
|
|
|
|
value: { payload },
|
|
|
|
|
send,
|
|
|
|
|
} = useWs(
|
|
|
|
|
`${camera}/motion_mask/${maskName}/state`,
|
|
|
|
|
`${camera}/motion_mask/${maskName}/set`,
|
|
|
|
|
);
|
|
|
|
|
return { payload: payload as ToggleableSetting, send };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function useObjectMaskState(
|
|
|
|
|
camera: string,
|
|
|
|
|
maskName: string,
|
|
|
|
|
): {
|
|
|
|
|
payload: ToggleableSetting;
|
|
|
|
|
send: (payload: ToggleableSetting, retain?: boolean) => void;
|
|
|
|
|
} {
|
|
|
|
|
const {
|
|
|
|
|
value: { payload },
|
|
|
|
|
send,
|
|
|
|
|
} = useWs(
|
|
|
|
|
`${camera}/object_mask/${maskName}/state`,
|
|
|
|
|
`${camera}/object_mask/${maskName}/set`,
|
|
|
|
|
);
|
|
|
|
|
return { payload: payload as ToggleableSetting, send };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function useZoneState(
|
|
|
|
|
camera: string,
|
|
|
|
|
zoneName: string,
|
|
|
|
|
): {
|
|
|
|
|
payload: ToggleableSetting;
|
|
|
|
|
send: (payload: ToggleableSetting, retain?: boolean) => void;
|
|
|
|
|
} {
|
|
|
|
|
const {
|
|
|
|
|
value: { payload },
|
|
|
|
|
send,
|
|
|
|
|
} = useWs(
|
|
|
|
|
`${camera}/zone/${zoneName}/state`,
|
|
|
|
|
`${camera}/zone/${zoneName}/set`,
|
|
|
|
|
);
|
|
|
|
|
return { payload: payload as ToggleableSetting, send };
|
|
|
|
|
}
|
|
|
|
|
|
2023-12-29 16:08:00 +03:00
|
|
|
export function usePtzCommand(camera: string): {
|
|
|
|
|
payload: string;
|
|
|
|
|
send: (payload: string, retain?: boolean) => void;
|
|
|
|
|
} {
|
2023-12-08 16:33:22 +03:00
|
|
|
const {
|
|
|
|
|
value: { payload },
|
|
|
|
|
send,
|
|
|
|
|
} = useWs(`${camera}/ptz`, `${camera}/ptz`);
|
2024-02-29 01:23:56 +03:00
|
|
|
return { payload: payload as string, send };
|
2023-12-08 16:33:22 +03:00
|
|
|
}
|
|
|
|
|
|
2023-12-29 16:08:00 +03:00
|
|
|
export function useRestart(): {
|
|
|
|
|
payload: string;
|
|
|
|
|
send: (payload: string, retain?: boolean) => void;
|
|
|
|
|
} {
|
2023-12-08 16:33:22 +03:00
|
|
|
const {
|
|
|
|
|
value: { payload },
|
|
|
|
|
send,
|
|
|
|
|
} = useWs("restart", "restart");
|
2024-02-29 01:23:56 +03:00
|
|
|
return { payload: payload as string, send };
|
2023-12-08 16:33:22 +03:00
|
|
|
}
|
2023-12-29 16:08:00 +03:00
|
|
|
|
|
|
|
|
export function useFrigateEvents(): { payload: FrigateEvent } {
|
|
|
|
|
const {
|
|
|
|
|
value: { payload },
|
2024-02-27 16:37:39 +03:00
|
|
|
} = useWs("events", "");
|
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.
2026-03-09 16:26:31 +03:00
|
|
|
const parsed = useMemo(
|
|
|
|
|
() => (payload ? JSON.parse(payload as string) : undefined),
|
|
|
|
|
[payload],
|
|
|
|
|
);
|
|
|
|
|
return { payload: parsed };
|
2024-02-27 16:37:39 +03:00
|
|
|
}
|
|
|
|
|
|
2025-08-25 21:40:21 +03:00
|
|
|
export function useAudioDetections(): { payload: FrigateAudioDetections } {
|
|
|
|
|
const {
|
|
|
|
|
value: { payload },
|
|
|
|
|
} = useWs("audio_detections", "");
|
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.
2026-03-09 16:26:31 +03:00
|
|
|
const parsed = useMemo(
|
|
|
|
|
() => (payload ? JSON.parse(payload as string) : undefined),
|
|
|
|
|
[payload],
|
|
|
|
|
);
|
|
|
|
|
return { payload: parsed };
|
2025-08-25 21:40:21 +03:00
|
|
|
}
|
|
|
|
|
|
2024-07-11 18:25:33 +03:00
|
|
|
export function useFrigateReviews(): FrigateReview {
|
2024-02-27 16:37:39 +03:00
|
|
|
const {
|
|
|
|
|
value: { payload },
|
|
|
|
|
} = useWs("reviews", "");
|
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.
2026-03-09 16:26:31 +03:00
|
|
|
return useMemo(
|
|
|
|
|
() => (payload ? JSON.parse(payload as string) : undefined),
|
|
|
|
|
[payload],
|
|
|
|
|
);
|
2023-12-29 16:08:00 +03:00
|
|
|
}
|
|
|
|
|
|
2024-07-11 18:25:33 +03:00
|
|
|
export function useFrigateStats(): FrigateStats {
|
2024-02-22 05:27:02 +03:00
|
|
|
const {
|
|
|
|
|
value: { payload },
|
|
|
|
|
} = useWs("stats", "");
|
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.
2026-03-09 16:26:31 +03:00
|
|
|
return useMemo(
|
|
|
|
|
() => (payload ? JSON.parse(payload as string) : undefined),
|
|
|
|
|
[payload],
|
|
|
|
|
);
|
2024-02-22 05:27:02 +03:00
|
|
|
}
|
|
|
|
|
|
2024-05-14 16:38:03 +03:00
|
|
|
export function useInitialCameraState(
|
|
|
|
|
camera: string,
|
2024-05-21 02:26:17 +03:00
|
|
|
revalidateOnFocus: boolean,
|
2024-05-14 16:38:03 +03:00
|
|
|
): {
|
2024-04-30 16:09:50 +03:00
|
|
|
payload: FrigateCameraState;
|
|
|
|
|
} {
|
|
|
|
|
const {
|
|
|
|
|
value: { payload },
|
2024-05-14 16:38:03 +03:00
|
|
|
send: sendCommand,
|
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.
2026-03-09 16:26:31 +03:00
|
|
|
} = useWs(`camera_activity/${camera}`, "onConnect");
|
2024-07-11 18:25:33 +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.
2026-03-09 16:26:31 +03:00
|
|
|
// camera_activity sub-topic payload is already parsed by expandCameraActivity
|
|
|
|
|
const data = payload as FrigateCameraState | undefined;
|
2024-05-14 16:38:03 +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.
2026-03-09 16:26:31 +03:00
|
|
|
// 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.
|
2024-05-14 16:38:03 +03:00
|
|
|
useEffect(() => {
|
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.
2026-03-09 16:26:31 +03:00
|
|
|
if (!revalidateOnFocus) return;
|
2024-05-21 02:26:17 +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.
2026-03-09 16:26:31 +03:00
|
|
|
const listener = () => {
|
|
|
|
|
if (document.visibilityState === "visible") {
|
|
|
|
|
sendCommand("onConnect");
|
2024-05-21 02:26:17 +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.
2026-03-09 16:26:31 +03:00
|
|
|
addEventListener("visibilitychange", listener);
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
removeEventListener("visibilitychange", listener);
|
|
|
|
|
};
|
2024-05-14 16:38:03 +03:00
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
2024-05-21 02:26:17 +03:00
|
|
|
}, [revalidateOnFocus]);
|
2024-05-14 16:38:03 +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.
2026-03-09 16:26:31 +03:00
|
|
|
return { payload: data as FrigateCameraState };
|
2024-04-30 16:09:50 +03:00
|
|
|
}
|
|
|
|
|
|
2024-10-07 23:30:45 +03:00
|
|
|
export function useModelState(
|
|
|
|
|
model: string,
|
|
|
|
|
revalidateOnFocus: boolean = true,
|
|
|
|
|
): { payload: ModelState } {
|
|
|
|
|
const {
|
|
|
|
|
value: { payload },
|
|
|
|
|
send: sendCommand,
|
|
|
|
|
} = useWs("model_state", "modelState");
|
|
|
|
|
|
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.
2026-03-09 16:26:31 +03:00
|
|
|
const data = useMemo(
|
|
|
|
|
() => (payload ? JSON.parse(payload as string) : undefined),
|
|
|
|
|
[payload],
|
|
|
|
|
);
|
2024-10-07 23:30:45 +03:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
let listener = undefined;
|
|
|
|
|
if (revalidateOnFocus) {
|
|
|
|
|
sendCommand("modelState");
|
|
|
|
|
listener = () => {
|
|
|
|
|
if (document.visibilityState == "visible") {
|
|
|
|
|
sendCommand("modelState");
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
addEventListener("visibilitychange", listener);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
if (listener) {
|
|
|
|
|
removeEventListener("visibilitychange", listener);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
// we know that these deps are correct
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, [revalidateOnFocus]);
|
|
|
|
|
|
|
|
|
|
return { payload: data ? data[model] : undefined };
|
|
|
|
|
}
|
|
|
|
|
|
2024-10-10 22:28:43 +03:00
|
|
|
export function useEmbeddingsReindexProgress(
|
|
|
|
|
revalidateOnFocus: boolean = true,
|
|
|
|
|
): {
|
|
|
|
|
payload: EmbeddingsReindexProgressType;
|
|
|
|
|
} {
|
|
|
|
|
const {
|
|
|
|
|
value: { payload },
|
|
|
|
|
send: sendCommand,
|
|
|
|
|
} = useWs("embeddings_reindex_progress", "embeddingsReindexProgress");
|
|
|
|
|
|
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.
2026-03-09 16:26:31 +03:00
|
|
|
const data = useMemo(
|
|
|
|
|
() => (payload ? JSON.parse(payload as string) : undefined),
|
|
|
|
|
[payload],
|
|
|
|
|
);
|
2024-10-10 22:28:43 +03:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
let listener = undefined;
|
|
|
|
|
if (revalidateOnFocus) {
|
|
|
|
|
sendCommand("embeddingsReindexProgress");
|
|
|
|
|
listener = () => {
|
|
|
|
|
if (document.visibilityState == "visible") {
|
|
|
|
|
sendCommand("embeddingsReindexProgress");
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
addEventListener("visibilitychange", listener);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return () => {
|
2025-06-08 21:06:17 +03:00
|
|
|
if (listener) {
|
|
|
|
|
removeEventListener("visibilitychange", listener);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
// we know that these deps are correct
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, [revalidateOnFocus]);
|
|
|
|
|
|
|
|
|
|
return { payload: data };
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-24 16:34:56 +03:00
|
|
|
export function useAudioTranscriptionProcessState(
|
|
|
|
|
revalidateOnFocus: boolean = true,
|
|
|
|
|
): { payload: string } {
|
|
|
|
|
const {
|
|
|
|
|
value: { payload },
|
|
|
|
|
send: sendCommand,
|
|
|
|
|
} = useWs("audio_transcription_state", "audioTranscriptionState");
|
|
|
|
|
|
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.
2026-03-09 16:26:31 +03:00
|
|
|
const data = useMemo(
|
|
|
|
|
() => (payload ? (JSON.parse(payload as string) as string) : "idle"),
|
|
|
|
|
[payload],
|
2025-11-24 16:34:56 +03:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
let listener = undefined;
|
|
|
|
|
if (revalidateOnFocus) {
|
|
|
|
|
sendCommand("audioTranscriptionState");
|
|
|
|
|
listener = () => {
|
|
|
|
|
if (document.visibilityState == "visible") {
|
|
|
|
|
sendCommand("audioTranscriptionState");
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
addEventListener("visibilitychange", listener);
|
|
|
|
|
}
|
|
|
|
|
return () => {
|
|
|
|
|
if (listener) {
|
|
|
|
|
removeEventListener("visibilitychange", listener);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, [revalidateOnFocus]);
|
|
|
|
|
|
|
|
|
|
return { payload: data || "idle" };
|
|
|
|
|
}
|
|
|
|
|
|
2025-06-08 21:06:17 +03:00
|
|
|
export function useBirdseyeLayout(revalidateOnFocus: boolean = true): {
|
|
|
|
|
payload: string;
|
|
|
|
|
} {
|
|
|
|
|
const {
|
|
|
|
|
value: { payload },
|
|
|
|
|
send: sendCommand,
|
|
|
|
|
} = useWs("birdseye_layout", "birdseyeLayout");
|
|
|
|
|
|
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.
2026-03-09 16:26:31 +03:00
|
|
|
const data = useMemo(
|
|
|
|
|
() => (payload ? JSON.parse(payload as string) : undefined),
|
|
|
|
|
[payload],
|
|
|
|
|
);
|
2025-06-08 21:06:17 +03:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
let listener = undefined;
|
|
|
|
|
if (revalidateOnFocus) {
|
|
|
|
|
sendCommand("birdseyeLayout");
|
|
|
|
|
listener = () => {
|
|
|
|
|
if (document.visibilityState == "visible") {
|
|
|
|
|
sendCommand("birdseyeLayout");
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
addEventListener("visibilitychange", listener);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return () => {
|
2024-10-10 22:28:43 +03:00
|
|
|
if (listener) {
|
|
|
|
|
removeEventListener("visibilitychange", listener);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
// we know that these deps are correct
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, [revalidateOnFocus]);
|
|
|
|
|
|
|
|
|
|
return { payload: data };
|
|
|
|
|
}
|
|
|
|
|
|
2023-12-29 16:08:00 +03:00
|
|
|
export function useMotionActivity(camera: string): { payload: string } {
|
|
|
|
|
const {
|
|
|
|
|
value: { payload },
|
|
|
|
|
} = useWs(`${camera}/motion`, "");
|
2024-02-29 01:23:56 +03:00
|
|
|
return { payload: payload as string };
|
2023-12-29 16:08:00 +03:00
|
|
|
}
|
|
|
|
|
|
2024-02-10 15:30:53 +03:00
|
|
|
export function useAudioActivity(camera: string): { payload: number } {
|
2023-12-29 16:08:00 +03:00
|
|
|
const {
|
|
|
|
|
value: { payload },
|
|
|
|
|
} = useWs(`${camera}/audio/rms`, "");
|
2024-02-29 01:23:56 +03:00
|
|
|
return { payload: payload as number };
|
2023-12-29 16:08:00 +03:00
|
|
|
}
|
2024-04-19 14:34:07 +03:00
|
|
|
|
2025-05-27 18:26:00 +03:00
|
|
|
export function useAudioLiveTranscription(camera: string): {
|
|
|
|
|
payload: string;
|
|
|
|
|
} {
|
|
|
|
|
const {
|
|
|
|
|
value: { payload },
|
|
|
|
|
} = useWs(`${camera}/audio/transcription`, "");
|
|
|
|
|
return { payload: payload as string };
|
|
|
|
|
}
|
|
|
|
|
|
2024-04-19 14:34:07 +03:00
|
|
|
export function useMotionThreshold(camera: string): {
|
|
|
|
|
payload: string;
|
|
|
|
|
send: (payload: number, retain?: boolean) => void;
|
|
|
|
|
} {
|
|
|
|
|
const {
|
|
|
|
|
value: { payload },
|
|
|
|
|
send,
|
|
|
|
|
} = useWs(
|
|
|
|
|
`${camera}/motion_threshold/state`,
|
|
|
|
|
`${camera}/motion_threshold/set`,
|
|
|
|
|
);
|
|
|
|
|
return { payload: payload as string, send };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function useMotionContourArea(camera: string): {
|
|
|
|
|
payload: string;
|
|
|
|
|
send: (payload: number, retain?: boolean) => void;
|
|
|
|
|
} {
|
|
|
|
|
const {
|
|
|
|
|
value: { payload },
|
|
|
|
|
send,
|
|
|
|
|
} = useWs(
|
|
|
|
|
`${camera}/motion_contour_area/state`,
|
|
|
|
|
`${camera}/motion_contour_area/set`,
|
|
|
|
|
);
|
|
|
|
|
return { payload: payload as string, send };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function useImproveContrast(camera: string): {
|
|
|
|
|
payload: ToggleableSetting;
|
|
|
|
|
send: (payload: string, retain?: boolean) => void;
|
|
|
|
|
} {
|
|
|
|
|
const {
|
|
|
|
|
value: { payload },
|
|
|
|
|
send,
|
|
|
|
|
} = useWs(
|
|
|
|
|
`${camera}/improve_contrast/state`,
|
|
|
|
|
`${camera}/improve_contrast/set`,
|
|
|
|
|
);
|
|
|
|
|
return { payload: payload as ToggleableSetting, send };
|
|
|
|
|
}
|
2024-09-24 17:14:51 +03:00
|
|
|
|
2025-05-27 18:26:00 +03:00
|
|
|
export function useTrackedObjectUpdate(): {
|
|
|
|
|
payload: TrackedObjectUpdateReturnType;
|
|
|
|
|
} {
|
2024-09-24 17:14:51 +03:00
|
|
|
const {
|
|
|
|
|
value: { payload },
|
2024-11-18 21:26:44 +03:00
|
|
|
} = useWs("tracked_object_update", "");
|
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.
2026-03-09 16:26:31 +03:00
|
|
|
const parsed = useMemo(
|
|
|
|
|
() =>
|
|
|
|
|
payload
|
|
|
|
|
? JSON.parse(payload as string)
|
|
|
|
|
: { type: "", id: "", camera: "" },
|
|
|
|
|
[payload],
|
|
|
|
|
);
|
|
|
|
|
return { payload: parsed };
|
2024-09-24 17:14:51 +03:00
|
|
|
}
|
2025-02-11 05:47:15 +03:00
|
|
|
|
|
|
|
|
export function useNotifications(camera: string): {
|
|
|
|
|
payload: ToggleableSetting;
|
|
|
|
|
send: (payload: string, retain?: boolean) => void;
|
|
|
|
|
} {
|
|
|
|
|
const {
|
|
|
|
|
value: { payload },
|
|
|
|
|
send,
|
|
|
|
|
} = useWs(`${camera}/notifications/state`, `${camera}/notifications/set`);
|
|
|
|
|
return { payload: payload as ToggleableSetting, send };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function useNotificationSuspend(camera: string): {
|
|
|
|
|
payload: string;
|
|
|
|
|
send: (payload: number, retain?: boolean) => void;
|
|
|
|
|
} {
|
|
|
|
|
const {
|
|
|
|
|
value: { payload },
|
|
|
|
|
send,
|
|
|
|
|
} = useWs(
|
|
|
|
|
`${camera}/notifications/suspended`,
|
|
|
|
|
`${camera}/notifications/suspend`,
|
|
|
|
|
);
|
|
|
|
|
return { payload: payload as string, send };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function useNotificationTest(): {
|
|
|
|
|
payload: string;
|
|
|
|
|
send: (payload: string, retain?: boolean) => void;
|
|
|
|
|
} {
|
|
|
|
|
const {
|
|
|
|
|
value: { payload },
|
|
|
|
|
send,
|
|
|
|
|
} = useWs("notification_test", "notification_test");
|
|
|
|
|
return { payload: payload as string, send };
|
|
|
|
|
}
|
2025-07-07 17:03:57 +03:00
|
|
|
|
|
|
|
|
export function useTriggers(): { payload: TriggerStatus } {
|
|
|
|
|
const {
|
|
|
|
|
value: { payload },
|
|
|
|
|
} = useWs("triggers", "");
|
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.
2026-03-09 16:26:31 +03:00
|
|
|
const parsed = useMemo(
|
|
|
|
|
() =>
|
|
|
|
|
payload
|
|
|
|
|
? JSON.parse(payload as string)
|
|
|
|
|
: { name: "", camera: "", event_id: "", type: "", score: 0 },
|
|
|
|
|
[payload],
|
|
|
|
|
);
|
|
|
|
|
return { payload: parsed };
|
2025-07-07 17:03:57 +03:00
|
|
|
}
|
2026-01-06 18:20:19 +03:00
|
|
|
|
|
|
|
|
export function useJobStatus(
|
|
|
|
|
jobType: string,
|
|
|
|
|
revalidateOnFocus: boolean = true,
|
|
|
|
|
): { payload: Job | null } {
|
|
|
|
|
const {
|
|
|
|
|
value: { payload },
|
|
|
|
|
send: sendCommand,
|
|
|
|
|
} = useWs("job_state", "jobState");
|
|
|
|
|
|
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.
2026-03-09 16:26:31 +03:00
|
|
|
const jobData = useMemo(
|
|
|
|
|
() => (payload && typeof payload === "string" ? JSON.parse(payload) : {}),
|
|
|
|
|
[payload],
|
2026-01-06 18:20:19 +03:00
|
|
|
);
|
|
|
|
|
const currentJob = jobData[jobType] || null;
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
let listener: (() => void) | undefined;
|
|
|
|
|
if (revalidateOnFocus) {
|
|
|
|
|
sendCommand("jobState");
|
|
|
|
|
listener = () => {
|
|
|
|
|
if (document.visibilityState === "visible") {
|
|
|
|
|
sendCommand("jobState");
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
addEventListener("visibilitychange", listener);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
if (listener) {
|
|
|
|
|
removeEventListener("visibilitychange", listener);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, [revalidateOnFocus]);
|
|
|
|
|
|
|
|
|
|
return { payload: currentJob as Job | null };
|
|
|
|
|
}
|
2026-03-04 19:07:34 +03:00
|
|
|
|
|
|
|
|
export function useWsMessageSubscribe(callback: (msg: WsFeedMessage) => void) {
|
|
|
|
|
const callbackRef = useRef(callback);
|
|
|
|
|
callbackRef.current = callback;
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const handler = (msg: WsFeedMessage) => callbackRef.current(msg);
|
|
|
|
|
wsMessageSubscribers.add(handler);
|
|
|
|
|
return () => {
|
|
|
|
|
wsMessageSubscribers.delete(handler);
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
}
|