diff --git a/web/package-lock.json b/web/package-lock.json index a3135a345..8c58d4c82 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -72,8 +72,6 @@ "react-markdown": "^9.0.1", "react-router-dom": "^6.30.3", "react-swipeable": "^7.0.2", - "react-tracked": "^2.0.1", - "react-use-websocket": "^4.8.1", "react-zoom-pan-pinch": "^3.7.0", "remark-gfm": "^4.0.0", "scroll-into-view-if-needed": "^3.1.0", @@ -4400,7 +4398,6 @@ "resolved": "https://registry.npmjs.org/@rjsf/core/-/core-6.3.1.tgz", "integrity": "sha512-LTjFz5Fk3FlbgFPJ+OJi1JdWJyiap9dSpx8W6u7JHNB7K5VbwzJe8gIU45XWLHzWFGDHKPm89VrUzjOs07TPtg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "lodash": "^4.17.23", "lodash-es": "^4.17.23", @@ -4475,7 +4472,6 @@ "resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-6.3.1.tgz", "integrity": "sha512-ve2KHl1ITYG8QIonnuK83/T1k/5NuxP4D1egVqP9Hz2ub28kgl0rNMwmRSxXs3WIbCcMW9g3ox+daVrbSNc4Mw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@x0k/json-schema-merge": "^1.0.2", "fast-uri": "^3.1.0", @@ -5149,7 +5145,6 @@ "integrity": "sha512-MdiXf+nDuMvY0gJKxyfZ7/6UFsETO7mGKF54MVD/ekJS6HdFtpZFBgrh6Pseu64XTb2MLyFPlbW6hj8HYRQNOQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~5.26.4" } @@ -5159,7 +5154,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -5170,7 +5164,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -5300,7 +5293,6 @@ "integrity": "sha512-dm/J2UDY3oV3TKius2OUZIFHsomQmpHtsV0FTh1WO8EKgHLQ1QCADUqscPgTpU+ih1e21FQSRjXckHn3txn6kQ==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.12.0", "@typescript-eslint/types": "7.12.0", @@ -5593,7 +5585,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", "dev": true, - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5755,7 +5746,6 @@ "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-3.52.0.tgz", "integrity": "sha512-7dg0ADKs8AA89iYMZMe2sFDG0XK5PfqllKV9N+i3hKHm3vEtdhwz8AlXGm+/b0nJ6jKiaXsqci5LfVxNhtB+dA==", "license": "MIT", - "peer": true, "dependencies": { "@yr/monotone-cubic-spline": "^1.0.3", "svg.draggable.js": "^2.2.2", @@ -5966,7 +5956,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001646", "electron-to-chromium": "^1.5.4", @@ -6467,7 +6456,6 @@ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/kossnocorp" @@ -6907,7 +6895,6 @@ "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -6963,7 +6950,6 @@ "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", "dev": true, - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -7887,7 +7873,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.23.2" }, @@ -8533,8 +8518,7 @@ "url": "https://github.com/sponsors/lavrton" } ], - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/levn": { "version": "0.4.1", @@ -9683,8 +9667,7 @@ "version": "0.52.2", "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz", "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/monaco-languageserver-types": { "version": "0.4.0", @@ -10392,7 +10375,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", @@ -10527,7 +10509,6 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -10676,11 +10657,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/proxy-compare": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-3.0.0.tgz", - "integrity": "sha512-y44MCkgtZUCT9tZGuE278fB7PWVf7fRYy0vbRXAts2o5F0EfC4fIQrvQQGBJo1WJbFcVLXzApOscyJuZqHQc1w==" - }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -10733,7 +10709,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -10798,7 +10773,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -10860,7 +10834,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.52.1.tgz", "integrity": "sha512-uNKIhaoICJ5KQALYZ4TOaOLElyM+xipord+Ha3crEFhTntdLvWZqVY49Wqd/0GiVCA/f9NjemLeiNPjG7Hpurg==", "license": "MIT", - "peer": true, "engines": { "node": ">=12.22.0" }, @@ -11115,29 +11088,6 @@ "react": "^16.8.3 || ^17 || ^18 || ^19.0.0 || ^19.0.0-rc" } }, - "node_modules/react-tracked": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/react-tracked/-/react-tracked-2.0.1.tgz", - "integrity": "sha512-qjbmtkO2IcW+rB2cFskRWDTjKs/w9poxvNnduacjQA04LWxOoLy9J8WfIEq1ahifQ/tVJQECrQPBm+UEzKRDtg==", - "license": "MIT", - "dependencies": { - "proxy-compare": "^3.0.0", - "use-context-selector": "^2.0.0" - }, - "peerDependencies": { - "react": ">=18.0.0", - "scheduler": ">=0.19.0" - } - }, - "node_modules/react-use-websocket": { - "version": "4.8.1", - "resolved": "https://registry.npmjs.org/react-use-websocket/-/react-use-websocket-4.8.1.tgz", - "integrity": "sha512-FTXuG5O+LFozmu1BRfrzl7UIQngECvGJmL7BHsK4TYXuVt+mCizVA8lT0hGSIF0Z0TedF7bOo1nRzOUdginhDw==", - "peerDependencies": { - "react": ">= 18.0.0", - "react-dom": ">= 18.0.0" - } - }, "node_modules/react-zoom-pan-pinch": { "version": "3.7.0", "resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-3.7.0.tgz", @@ -11549,8 +11499,7 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/scroll-into-view-if-needed": { "version": "3.1.0", @@ -12049,7 +11998,6 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.9.tgz", "integrity": "sha512-1SEOvRr6sSdV5IDf9iC+NU4dhwdqzF4zKKq3sAbasUWHEM6lsMhX+eNN5gkPx1BvLFEnZQEUFbXnGj8Qlp83Pg==", "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -12232,7 +12180,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -12411,7 +12358,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12627,15 +12573,6 @@ } } }, - "node_modules/use-context-selector": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/use-context-selector/-/use-context-selector-2.0.0.tgz", - "integrity": "sha512-owfuSmUNd3eNp3J9CdDl0kMgfidV+MkDvHPpvthN5ThqM+ibMccNE0k+Iq7TWC6JPFvGZqanqiGCuQx6DyV24g==", - "peerDependencies": { - "react": ">=18.0.0", - "scheduler": ">=0.19.0" - } - }, "node_modules/use-long-press": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/use-long-press/-/use-long-press-3.2.0.tgz", @@ -12771,7 +12708,6 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -12896,7 +12832,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -12910,7 +12845,6 @@ "integrity": "sha512-IP7gPK3LS3Fvn44x30X1dM9vtawm0aesAa2yBIZ9vQf+qB69NXC5776+Qmcr7ohUXIQuLhk7xQR0aSUIDPqavg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "3.0.7", "@vitest/mocker": "3.0.7", diff --git a/web/package.json b/web/package.json index acbbd8d88..a6d67e8c2 100644 --- a/web/package.json +++ b/web/package.json @@ -78,8 +78,6 @@ "react-markdown": "^9.0.1", "react-router-dom": "^6.30.3", "react-swipeable": "^7.0.2", - "react-tracked": "^2.0.1", - "react-use-websocket": "^4.8.1", "react-zoom-pan-pinch": "^3.7.0", "remark-gfm": "^4.0.0", "scroll-into-view-if-needed": "^3.1.0", diff --git a/web/patches/react-use-websocket+4.8.1.patch b/web/patches/react-use-websocket+4.8.1.patch deleted file mode 100644 index 5de81c525..000000000 --- a/web/patches/react-use-websocket+4.8.1.patch +++ /dev/null @@ -1,23 +0,0 @@ -diff --git a/node_modules/react-use-websocket/dist/lib/use-websocket.js b/node_modules/react-use-websocket/dist/lib/use-websocket.js -index f01db48..b30aff2 100644 ---- a/node_modules/react-use-websocket/dist/lib/use-websocket.js -+++ b/node_modules/react-use-websocket/dist/lib/use-websocket.js -@@ -139,15 +139,15 @@ var useWebSocket = function (url, options, connect) { - } - protectedSetLastMessage = function (message) { - if (!expectClose_1) { -- (0, react_dom_1.flushSync)(function () { return setLastMessage(message); }); -+ setLastMessage(message); - } - }; - protectedSetReadyState = function (state) { - if (!expectClose_1) { -- (0, react_dom_1.flushSync)(function () { return setReadyState(function (prev) { -+ setReadyState(function (prev) { - var _a; - return (__assign(__assign({}, prev), (convertedUrl.current && (_a = {}, _a[convertedUrl.current] = state, _a)))); -- }); }); -+ }); - } - }; - if (createOrJoin_1) { diff --git a/web/src/api/WsProvider.tsx b/web/src/api/WsProvider.tsx new file mode 100644 index 000000000..4e4f72490 --- /dev/null +++ b/web/src/api/WsProvider.tsx @@ -0,0 +1,78 @@ +import { baseUrl } from "./baseUrl"; +import { ReactNode, useCallback, useEffect, useRef } from "react"; +import { WsSendContext } from "./wsContext"; +import type { Update } from "./wsContext"; +import { processWsMessage, resetWsStore } from "./ws"; + +export function WsProvider({ children }: { children: ReactNode }) { + const wsUrl = `${baseUrl.replace(/^http/, "ws")}ws`; + const wsRef = useRef(null); + const reconnectTimer = useRef | null>(null); + const reconnectAttempt = useRef(0); + const unmounted = useRef(false); + + const sendJsonMessage = useCallback((msg: unknown) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify(msg)); + } + }, []); + + useEffect(() => { + unmounted.current = false; + + function connect() { + if (unmounted.current) return; + + const ws = new WebSocket(wsUrl); + wsRef.current = ws; + + ws.onopen = () => { + reconnectAttempt.current = 0; + ws.send( + JSON.stringify({ topic: "onConnect", message: "", retain: false }), + ); + }; + + ws.onmessage = (event: MessageEvent) => { + processWsMessage(event.data as string); + }; + + ws.onclose = () => { + if (unmounted.current) return; + const delay = Math.min(1000 * 2 ** reconnectAttempt.current, 30000); + reconnectAttempt.current++; + reconnectTimer.current = setTimeout(connect, delay); + }; + + ws.onerror = () => { + ws.close(); + }; + } + + connect(); + + return () => { + unmounted.current = true; + if (reconnectTimer.current) { + clearTimeout(reconnectTimer.current); + } + wsRef.current?.close(); + resetWsStore(); + }; + }, [wsUrl]); + + const send = useCallback( + (message: Update) => { + sendJsonMessage({ + topic: message.topic, + payload: message.payload, + retain: message.retain, + }); + }, + [sendJsonMessage], + ); + + return ( + {children} + ); +} diff --git a/web/src/api/index.tsx b/web/src/api/index.tsx index e5c5617ab..41cb7d24c 100644 --- a/web/src/api/index.tsx +++ b/web/src/api/index.tsx @@ -1,6 +1,6 @@ import { baseUrl } from "./baseUrl"; import { SWRConfig } from "swr"; -import { WsProvider } from "./ws"; +import { WsProvider } from "./WsProvider"; import axios from "axios"; import { ReactNode } from "react"; import { isRedirectingToLogin, setRedirectingToLogin } from "./auth-redirect"; diff --git a/web/src/api/ws.tsx b/web/src/api/ws.ts similarity index 65% rename from web/src/api/ws.tsx rename to web/src/api/ws.ts index 07d44d67a..845ad1fa6 100644 --- a/web/src/api/ws.tsx +++ b/web/src/api/ws.ts @@ -1,6 +1,11 @@ -import { baseUrl } from "./baseUrl"; -import { useCallback, useEffect, useRef, useState } from "react"; -import useWebSocket, { ReadyState } from "react-use-websocket"; +import { + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useSyncExternalStore, +} from "react"; import { EmbeddingsReindexProgressType, FrigateCameraState, @@ -14,8 +19,11 @@ import { Job, } from "@/types/ws"; import { FrigateStats } from "@/types/stats"; -import { createContainer } from "react-tracked"; -import useDeepMemo from "@/hooks/use-deep-memo"; +import { isEqual } from "lodash"; +import { WsSendContext } from "./wsContext"; +import type { Update, WsSend } from "./wsContext"; + +export type { Update }; export type WsFeedMessage = { topic: string; @@ -24,170 +32,204 @@ export type WsFeedMessage = { id: string; }; -type Update = { - topic: string; - payload: unknown; - retain: boolean; -}; - type WsState = { [topic: string]: unknown; }; -type useValueReturn = [WsState, (update: Update) => void]; +// External store for WebSocket state using useSyncExternalStore +type Listener = () => void; +const wsState: WsState = {}; +const wsTopicListeners = new Map>(); + +// Reset all module-level state. Called on WsProvider unmount to prevent +// stale data from leaking across mount/unmount cycles (e.g. HMR, logout) +export function resetWsStore() { + for (const key of Object.keys(wsState)) { + delete wsState[key]; + } + wsTopicListeners.clear(); + lastCameraActivityPayload = null; + wsMessageSubscribers.clear(); + wsMessageIdCounter = 0; +} + +// Parse and apply a raw WS message synchronously. +// Called directly from WsProvider's onmessage handler. +export function processWsMessage(raw: string) { + const data: Update = JSON.parse(raw); + if (!data) return; + + const { topic, payload } = data; + + if (topic === "camera_activity") { + applyCameraActivity(payload as string); + } else { + applyTopicUpdate(topic, payload); + } + + if (wsMessageSubscribers.size > 0) { + wsMessageSubscribers.forEach((cb) => + cb({ + topic, + payload, + timestamp: Date.now(), + id: String(wsMessageIdCounter++), + }), + ); + } +} + +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(); + } +} + +// Subscriptions + +export function subscribeWsTopic( + topic: string, + listener: Listener, +): () => void { + let set = wsTopicListeners.get(topic); + if (!set) { + set = new Set(); + wsTopicListeners.set(topic, set); + } + set.add(listener); + return () => { + set!.delete(listener); + if (set!.size === 0) wsTopicListeners.delete(topic); + }; +} + +export function getWsTopicValue(topic: string): unknown { + return wsState[topic]; +} + +// Feed message subscribers const wsMessageSubscribers = new Set<(msg: WsFeedMessage) => void>(); let wsMessageIdCounter = 0; -function useValue(): useValueReturn { - const wsUrl = `${baseUrl.replace(/^http/, "ws")}ws`; +// Camera activity expansion +// +// 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; - // main state +function applyCameraActivity(payload: string) { + // Fast path: if the raw JSON string is identical, nothing changed. + if (payload === lastCameraActivityPayload) return; + lastCameraActivityPayload = payload; - const [wsState, setWsState] = useState({}); + let activity: { [key: string]: Partial }; - useEffect(() => { - const activityValue: string = wsState["camera_activity"] as string; + try { + activity = JSON.parse(payload); + } catch { + return; + } - if (!activityValue) { - return; - } + if (Object.keys(activity).length === 0) return; - let cameraActivity: { [key: string]: Partial }; + for (const [name, state] of Object.entries(activity)) { + applyTopicUpdate(`camera_activity/${name}`, state); - try { - cameraActivity = JSON.parse(activityValue); - } catch { - return; - } + const cameraConfig = state?.config; + if (!cameraConfig) continue; - if (Object.keys(cameraActivity).length === 0) { - return; - } + const { + record, + detect, + enabled, + snapshots, + audio, + audio_transcription, + notifications, + notifications_suspended, + autotracking, + alerts, + detections, + object_descriptions, + review_descriptions, + } = cameraConfig; - const cameraStates: WsState = {}; - - Object.entries(cameraActivity).forEach(([name, state]) => { - const cameraConfig = state?.config; - - if (!cameraConfig) { - return; - } - - const { - record, - detect, - enabled, - snapshots, - audio, - audio_transcription, - notifications, - notifications_suspended, - autotracking, - alerts, - detections, - object_descriptions, - review_descriptions, - } = cameraConfig; - cameraStates[`${name}/recordings/state`] = record ? "ON" : "OFF"; - cameraStates[`${name}/enabled/state`] = enabled ? "ON" : "OFF"; - cameraStates[`${name}/detect/state`] = detect ? "ON" : "OFF"; - cameraStates[`${name}/snapshots/state`] = snapshots ? "ON" : "OFF"; - cameraStates[`${name}/audio/state`] = audio ? "ON" : "OFF"; - cameraStates[`${name}/audio_transcription/state`] = audio_transcription - ? "ON" - : "OFF"; - cameraStates[`${name}/notifications/state`] = notifications - ? "ON" - : "OFF"; - cameraStates[`${name}/notifications/suspended`] = - notifications_suspended || 0; - cameraStates[`${name}/ptz_autotracker/state`] = autotracking - ? "ON" - : "OFF"; - cameraStates[`${name}/review_alerts/state`] = alerts ? "ON" : "OFF"; - cameraStates[`${name}/review_detections/state`] = detections - ? "ON" - : "OFF"; - cameraStates[`${name}/object_descriptions/state`] = object_descriptions - ? "ON" - : "OFF"; - cameraStates[`${name}/review_descriptions/state`] = review_descriptions - ? "ON" - : "OFF"; - }); - - setWsState((prevState) => ({ - ...prevState, - ...cameraStates, - })); - - // we only want this to run initially when the config is loaded - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [wsState["camera_activity"]]); - - // ws handler - const { sendJsonMessage, readyState } = useWebSocket(wsUrl, { - onMessage: (event) => { - const data: Update = JSON.parse(event.data); - - if (data) { - setWsState((prevState) => ({ - ...prevState, - [data.topic]: data.payload, - })); - - // Notify feed subscribers - if (wsMessageSubscribers.size > 0) { - const feedMsg: WsFeedMessage = { - topic: data.topic, - payload: data.payload, - timestamp: Date.now(), - id: String(wsMessageIdCounter++), - }; - wsMessageSubscribers.forEach((cb) => cb(feedMsg)); - } - } - }, - onOpen: () => { - sendJsonMessage({ - topic: "onConnect", - message: "", - retain: false, - }); - }, - onClose: () => {}, - shouldReconnect: () => true, - retryOnError: true, - }); - - const setState = useCallback( - (message: Update) => { - if (readyState === ReadyState.OPEN) { - sendJsonMessage({ - topic: message.topic, - payload: message.payload, - retain: message.retain, - }); - } - }, - [readyState, sendJsonMessage], - ); - - return [wsState, setState]; + 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", + ); + } } -export const { - Provider: WsProvider, - useTrackedState: useWsState, - useUpdate: useWsUpdate, -} = createContainer(useValue, { defaultState: {}, concurrentMode: true }); +// Hooks +export function useWsUpdate(): WsSend { + const send = useContext(WsSendContext); + if (!send) { + throw new Error("useWsUpdate must be used within WsProvider"); + } + return send; +} +// Subscribe to a single WS topic with proper bail-out. +// Only re-renders when the topic's value changes (Object.is comparison). +// Uses useSyncExternalStore — zero useEffect, so no PassiveMask flags +// propagate through the fiber tree. export function useWs(watchTopic: string, publishTopic: string) { - const state = useWsState(); + const payload = useSyncExternalStore( + useCallback( + (listener: Listener) => subscribeWsTopic(watchTopic, listener), + [watchTopic], + ), + useCallback(() => wsState[watchTopic], [watchTopic]), + ); + const sendJsonMessage = useWsUpdate(); - const value = { payload: state[watchTopic] || null }; + const value = { payload: payload ?? null }; const send = useCallback( (payload: unknown, retain = false) => { @@ -203,6 +245,8 @@ export function useWs(watchTopic: string, publishTopic: string) { return { value, send }; } +// Convenience hooks + export function useEnabledState(camera: string): { payload: ToggleableSetting; send: (payload: ToggleableSetting, retain?: boolean) => void; @@ -413,28 +457,42 @@ export function useFrigateEvents(): { payload: FrigateEvent } { const { value: { payload }, } = useWs("events", ""); - return { payload: JSON.parse(payload as string) }; + const parsed = useMemo( + () => (payload ? JSON.parse(payload as string) : undefined), + [payload], + ); + return { payload: parsed }; } export function useAudioDetections(): { payload: FrigateAudioDetections } { const { value: { payload }, } = useWs("audio_detections", ""); - return { payload: JSON.parse(payload as string) }; + const parsed = useMemo( + () => (payload ? JSON.parse(payload as string) : undefined), + [payload], + ); + return { payload: parsed }; } export function useFrigateReviews(): FrigateReview { const { value: { payload }, } = useWs("reviews", ""); - return useDeepMemo(JSON.parse(payload as string)); + return useMemo( + () => (payload ? JSON.parse(payload as string) : undefined), + [payload], + ); } export function useFrigateStats(): FrigateStats { const { value: { payload }, } = useWs("stats", ""); - return useDeepMemo(JSON.parse(payload as string)); + return useMemo( + () => (payload ? JSON.parse(payload as string) : undefined), + [payload], + ); } export function useInitialCameraState( @@ -446,32 +504,31 @@ export function useInitialCameraState( const { value: { payload }, send: sendCommand, - } = useWs("camera_activity", "onConnect"); + } = useWs(`camera_activity/${camera}`, "onConnect"); - const data = useDeepMemo(JSON.parse(payload as string)); + // camera_activity sub-topic payload is already parsed by expandCameraActivity + const data = payload as FrigateCameraState | undefined; + // onConnect is sent once in WsProvider.onopen — no need to re-request on + // every component mount. Components read cached wsState immediately via + // useSyncExternalStore. Only re-request when the user tabs back in. useEffect(() => { - let listener = undefined; - if (revalidateOnFocus) { - sendCommand("onConnect"); - listener = () => { - if (document.visibilityState == "visible") { - sendCommand("onConnect"); - } - }; - addEventListener("visibilitychange", listener); - } + if (!revalidateOnFocus) return; - return () => { - if (listener) { - removeEventListener("visibilitychange", listener); + const listener = () => { + if (document.visibilityState === "visible") { + sendCommand("onConnect"); } }; - // only refresh when onRefresh value changes + addEventListener("visibilitychange", listener); + + return () => { + removeEventListener("visibilitychange", listener); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [revalidateOnFocus]); - return { payload: data ? data[camera] : undefined }; + return { payload: data as FrigateCameraState }; } export function useModelState( @@ -483,7 +540,10 @@ export function useModelState( send: sendCommand, } = useWs("model_state", "modelState"); - const data = useDeepMemo(JSON.parse(payload as string)); + const data = useMemo( + () => (payload ? JSON.parse(payload as string) : undefined), + [payload], + ); useEffect(() => { let listener = undefined; @@ -519,7 +579,10 @@ export function useEmbeddingsReindexProgress( send: sendCommand, } = useWs("embeddings_reindex_progress", "embeddingsReindexProgress"); - const data = useDeepMemo(JSON.parse(payload as string)); + const data = useMemo( + () => (payload ? JSON.parse(payload as string) : undefined), + [payload], + ); useEffect(() => { let listener = undefined; @@ -553,8 +616,9 @@ export function useAudioTranscriptionProcessState( send: sendCommand, } = useWs("audio_transcription_state", "audioTranscriptionState"); - const data = useDeepMemo( - payload ? (JSON.parse(payload as string) as string) : "idle", + const data = useMemo( + () => (payload ? (JSON.parse(payload as string) as string) : "idle"), + [payload], ); useEffect(() => { @@ -587,7 +651,10 @@ export function useBirdseyeLayout(revalidateOnFocus: boolean = true): { send: sendCommand, } = useWs("birdseye_layout", "birdseyeLayout"); - const data = useDeepMemo(JSON.parse(payload as string)); + const data = useMemo( + () => (payload ? JSON.parse(payload as string) : undefined), + [payload], + ); useEffect(() => { let listener = undefined; @@ -684,10 +751,14 @@ export function useTrackedObjectUpdate(): { const { value: { payload }, } = useWs("tracked_object_update", ""); - const parsed = payload - ? JSON.parse(payload as string) - : { type: "", id: "", camera: "" }; - return { payload: useDeepMemo(parsed) }; + const parsed = useMemo( + () => + payload + ? JSON.parse(payload as string) + : { type: "", id: "", camera: "" }, + [payload], + ); + return { payload: parsed }; } export function useNotifications(camera: string): { @@ -730,10 +801,14 @@ export function useTriggers(): { payload: TriggerStatus } { const { value: { payload }, } = useWs("triggers", ""); - const parsed = payload - ? JSON.parse(payload as string) - : { name: "", camera: "", event_id: "", type: "", score: 0 }; - return { payload: useDeepMemo(parsed) }; + const parsed = useMemo( + () => + payload + ? JSON.parse(payload as string) + : { name: "", camera: "", event_id: "", type: "", score: 0 }, + [payload], + ); + return { payload: parsed }; } export function useJobStatus( @@ -745,8 +820,9 @@ export function useJobStatus( send: sendCommand, } = useWs("job_state", "jobState"); - const jobData = useDeepMemo( - payload && typeof payload === "string" ? JSON.parse(payload) : {}, + const jobData = useMemo( + () => (payload && typeof payload === "string" ? JSON.parse(payload) : {}), + [payload], ); const currentJob = jobData[jobType] || null; diff --git a/web/src/api/wsContext.ts b/web/src/api/wsContext.ts new file mode 100644 index 000000000..15fee8ffa --- /dev/null +++ b/web/src/api/wsContext.ts @@ -0,0 +1,11 @@ +import { createContext } from "react"; + +export type Update = { + topic: string; + payload: unknown; + retain: boolean; +}; + +export type WsSend = (update: Update) => void; + +export const WsSendContext = createContext(null); diff --git a/web/src/components/dynamic/TimeAgo.tsx b/web/src/components/dynamic/TimeAgo.tsx index b8b7c3f8b..c2d7668f7 100644 --- a/web/src/components/dynamic/TimeAgo.tsx +++ b/web/src/components/dynamic/TimeAgo.tsx @@ -98,10 +98,10 @@ const TimeAgo: FunctionComponent = ({ return manualRefreshInterval; } - const currentTs = currentTime.getTime() / 1000; - if (currentTs - time < 60) { + const elapsedMs = currentTime.getTime() - time; + if (elapsedMs < 60000) { return 1000; // refresh every second - } else if (currentTs - time < 3600) { + } else if (elapsedMs < 3600000) { return 60000; // refresh every minute } else { return 3600000; // refresh every hour diff --git a/web/src/hooks/use-polygon-states.ts b/web/src/hooks/use-polygon-states.ts index 6eea109db..7cb55faf0 100644 --- a/web/src/hooks/use-polygon-states.ts +++ b/web/src/hooks/use-polygon-states.ts @@ -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(); + 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(); + // Build value map from snapshot + const valueMap = new Map(); + 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(); 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]); }