Replace react-tracked and react-use-websocket with useSyncExternalStore (#22386)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions

* 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
This commit is contained in:
Josh Hawkins
2026-03-11 09:02:51 -05:00
committed by GitHub
parent 947ddfa542
commit 192aba901a
9 changed files with 415 additions and 289 deletions
+60 -8
View File
@@ -1,18 +1,70 @@
import { useMemo } from "react";
import { useCallback, useMemo, useSyncExternalStore } from "react";
import { Polygon } from "@/types/canvas";
import { useWsState } from "@/api/ws";
import { subscribeWsTopic, getWsTopicValue } from "@/api/ws";
/**
* Hook to get enabled state for a polygon from websocket state.
* Memoizes the lookup function to avoid unnecessary re-renders.
* 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[]) {
const wsState = useWsState();
// 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]);
// Create a memoized lookup map that only updates when relevant ws values change
// 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(() => {
const stateMap = new Map<string, boolean>();
// 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);
}
});
const stateMap = new Map<string, boolean>();
polygons.forEach((polygon) => {
const topic =
polygon.type === "zone"
@@ -21,7 +73,7 @@ export function usePolygonStates(polygons: Polygon[]) {
? `${polygon.camera}/motion_mask/${polygon.name}/state`
: `${polygon.camera}/object_mask/${polygon.name}/state`;
const wsValue = wsState[topic];
const wsValue = valueMap.get(topic);
const enabled =
wsValue === "ON"
? true
@@ -40,5 +92,5 @@ export function usePolygonStates(polygons: Polygon[]) {
true
);
};
}, [polygons, wsState]);
}, [polygons, snapshot]);
}