mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-12 03:17:36 +03:00
Replace react-tracked and react-use-websocket with useSyncExternalStore (#22386)
Some checks are pending
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
Some checks are pending
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:
parent
947ddfa542
commit
192aba901a
72
web/package-lock.json
generated
72
web/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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) {
|
||||
78
web/src/api/WsProvider.tsx
Normal file
78
web/src/api/WsProvider.tsx
Normal file
@ -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<WebSocket | null>(null);
|
||||
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const reconnectAttempt = useRef(0);
|
||||
const unmounted = useRef(false);
|
||||
|
||||
const sendJsonMessage = useCallback((msg: unknown) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify(msg));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
unmounted.current = false;
|
||||
|
||||
function connect() {
|
||||
if (unmounted.current) return;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
reconnectAttempt.current = 0;
|
||||
ws.send(
|
||||
JSON.stringify({ topic: "onConnect", message: "", retain: false }),
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<WsSendContext.Provider value={send}>{children}</WsSendContext.Provider>
|
||||
);
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -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<string, Set<Listener>>();
|
||||
|
||||
// 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<WsState>({});
|
||||
let activity: { [key: string]: Partial<FrigateCameraState> };
|
||||
|
||||
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<FrigateCameraState> };
|
||||
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;
|
||||
|
||||
11
web/src/api/wsContext.ts
Normal file
11
web/src/api/wsContext.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
export type Update = {
|
||||
topic: string;
|
||||
payload: unknown;
|
||||
retain: boolean;
|
||||
};
|
||||
|
||||
export type WsSend = (update: Update) => void;
|
||||
|
||||
export const WsSendContext = createContext<WsSend | null>(null);
|
||||
@ -98,10 +98,10 @@ const TimeAgo: FunctionComponent<IProp> = ({
|
||||
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
|
||||
|
||||
@ -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]);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user