frigate/web/src/hooks/use-polygon-states.ts

97 lines
3.2 KiB
TypeScript
Raw Normal View History

Replace react-tracked and react-use-websocket with useSyncExternalStore (#22386) * 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. * remove react-tracked and react-use-websocket * refactor usePolygonStates to use ws topic subscription * fix TimeAgo refresh interval always returning 1s due to unit mismatch (seconds vs milliseconds) older events now correctly refresh every minute/hour instead of every second * simplify * clean up * don't resend onconnect * clean up * remove patch
2026-03-11 17:02:51 +03:00
import { useCallback, useMemo, useSyncExternalStore } from "react";
import { Polygon } from "@/types/canvas";
Replace react-tracked and react-use-websocket with useSyncExternalStore (#22386) * 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. * remove react-tracked and react-use-websocket * refactor usePolygonStates to use ws topic subscription * fix TimeAgo refresh interval always returning 1s due to unit mismatch (seconds vs milliseconds) older events now correctly refresh every minute/hour instead of every second * simplify * clean up * don't resend onconnect * clean up * remove patch
2026-03-11 17:02:51 +03:00
import { subscribeWsTopic, getWsTopicValue } from "@/api/ws";
/**
* Hook to get enabled state for a polygon from websocket state.
Replace react-tracked and react-use-websocket with useSyncExternalStore (#22386) * 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. * remove react-tracked and react-use-websocket * refactor usePolygonStates to use ws topic subscription * fix TimeAgo refresh interval always returning 1s due to unit mismatch (seconds vs milliseconds) older events now correctly refresh every minute/hour instead of every second * simplify * clean up * don't resend onconnect * clean up * remove patch
2026-03-11 17:02:51 +03:00
* Subscribes to all relevant per-polygon topics so it only re-renders
* when one of those specific topics changes not on every WS update.
*/
export function usePolygonStates(polygons: Polygon[]) {
Replace react-tracked and react-use-websocket with useSyncExternalStore (#22386) * 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. * remove react-tracked and react-use-websocket * refactor usePolygonStates to use ws topic subscription * fix TimeAgo refresh interval always returning 1s due to unit mismatch (seconds vs milliseconds) older events now correctly refresh every minute/hour instead of every second * simplify * clean up * don't resend onconnect * clean up * remove patch
2026-03-11 17:02:51 +03:00
// Build a stable sorted list of topics we need to watch
const topics = useMemo(() => {
const set = new Set<string>();
polygons.forEach((polygon) => {
const topic =
polygon.type === "zone"
? `${polygon.camera}/zone/${polygon.name}/state`
: polygon.type === "motion_mask"
? `${polygon.camera}/motion_mask/${polygon.name}/state`
: `${polygon.camera}/object_mask/${polygon.name}/state`;
set.add(topic);
});
return Array.from(set).sort();
}, [polygons]);
Replace react-tracked and react-use-websocket with useSyncExternalStore (#22386) * 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. * remove react-tracked and react-use-websocket * refactor usePolygonStates to use ws topic subscription * fix TimeAgo refresh interval always returning 1s due to unit mismatch (seconds vs milliseconds) older events now correctly refresh every minute/hour instead of every second * simplify * clean up * don't resend onconnect * clean up * remove patch
2026-03-11 17:02:51 +03:00
// Stable key for the topic list so subscribe/getSnapshot stay in sync
const topicsKey = topics.join("\0");
// Subscribe to all topics at once — re-subscribe only when the set changes
const subscribe = useCallback(
(listener: () => void) => {
const unsubscribes = topicsKey
.split("\0")
.filter(Boolean)
.map((topic) => subscribeWsTopic(topic, listener));
return () => unsubscribes.forEach((unsub) => unsub());
},
[topicsKey],
);
// Build a snapshot string from the current values of all topics.
// useSyncExternalStore uses Object.is, so we return a primitive that
// changes only when an observed topic's value changes.
const getSnapshot = useCallback(() => {
return topicsKey
.split("\0")
.filter(Boolean)
.map((topic) => `${topic}=${getWsTopicValue(topic) ?? ""}`)
.join("\0");
}, [topicsKey]);
const snapshot = useSyncExternalStore(subscribe, getSnapshot);
// Parse the snapshot into a lookup map
return useMemo(() => {
Replace react-tracked and react-use-websocket with useSyncExternalStore (#22386) * 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. * remove react-tracked and react-use-websocket * refactor usePolygonStates to use ws topic subscription * fix TimeAgo refresh interval always returning 1s due to unit mismatch (seconds vs milliseconds) older events now correctly refresh every minute/hour instead of every second * simplify * clean up * don't resend onconnect * clean up * remove patch
2026-03-11 17:02:51 +03:00
// Build value map from snapshot
const valueMap = new Map<string, unknown>();
snapshot.split("\0").forEach((entry) => {
const eqIdx = entry.indexOf("=");
if (eqIdx > 0) {
const topic = entry.slice(0, eqIdx);
const val = entry.slice(eqIdx + 1) || undefined;
valueMap.set(topic, val);
}
});
Replace react-tracked and react-use-websocket with useSyncExternalStore (#22386) * 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. * remove react-tracked and react-use-websocket * refactor usePolygonStates to use ws topic subscription * fix TimeAgo refresh interval always returning 1s due to unit mismatch (seconds vs milliseconds) older events now correctly refresh every minute/hour instead of every second * simplify * clean up * don't resend onconnect * clean up * remove patch
2026-03-11 17:02:51 +03:00
const stateMap = new Map<string, boolean>();
polygons.forEach((polygon) => {
const topic =
polygon.type === "zone"
? `${polygon.camera}/zone/${polygon.name}/state`
: polygon.type === "motion_mask"
? `${polygon.camera}/motion_mask/${polygon.name}/state`
: `${polygon.camera}/object_mask/${polygon.name}/state`;
Replace react-tracked and react-use-websocket with useSyncExternalStore (#22386) * 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. * remove react-tracked and react-use-websocket * refactor usePolygonStates to use ws topic subscription * fix TimeAgo refresh interval always returning 1s due to unit mismatch (seconds vs milliseconds) older events now correctly refresh every minute/hour instead of every second * simplify * clean up * don't resend onconnect * clean up * remove patch
2026-03-11 17:02:51 +03:00
const wsValue = valueMap.get(topic);
const enabled =
wsValue === "ON"
? true
: wsValue === "OFF"
? false
: (polygon.enabled ?? true);
stateMap.set(
`${polygon.camera}/${polygon.type}/${polygon.name}`,
enabled,
);
});
return (polygon: Polygon) => {
return (
stateMap.get(`${polygon.camera}/${polygon.type}/${polygon.name}`) ??
true
);
};
Replace react-tracked and react-use-websocket with useSyncExternalStore (#22386) * 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. * remove react-tracked and react-use-websocket * refactor usePolygonStates to use ws topic subscription * fix TimeAgo refresh interval always returning 1s due to unit mismatch (seconds vs milliseconds) older events now correctly refresh every minute/hour instead of every second * simplify * clean up * don't resend onconnect * clean up * remove patch
2026-03-11 17:02:51 +03:00
}, [polygons, snapshot]);
}