From cb337877fe4d25ffe5ebed3a97c9961292c12388 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Tue, 27 Feb 2024 05:49:32 -0700 Subject: [PATCH] Rewrite websocket to use tracked state instead of context --- web/package-lock.json | 48 +++++++++++ web/package.json | 1 + web/src/api/ws.tsx | 193 ++++++++++++++++-------------------------- 3 files changed, 123 insertions(+), 119 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index f896f7c8a..13be9ab59 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -46,6 +46,7 @@ "react-hook-form": "^7.48.2", "react-icons": "^4.12.0", "react-router-dom": "^6.20.1", + "react-tracked": "^1.7.11", "react-transition-group": "^4.4.5", "react-use-websocket": "^4.5.0", "recoil": "^0.7.7", @@ -6498,6 +6499,11 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/proxy-compare": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-2.4.0.tgz", + "integrity": "sha512-FD8KmQUQD6Mfpd0hywCOzcon/dbkFP8XBd9F1ycbKtvVsfv6TsFUKJ2eC0Iz2y+KzlkdT1Z8SY6ZSgm07zOyqg==" + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -6729,6 +6735,29 @@ } } }, + "node_modules/react-tracked": { + "version": "1.7.11", + "resolved": "https://registry.npmjs.org/react-tracked/-/react-tracked-1.7.11.tgz", + "integrity": "sha512-+XXv4dJH7NnLtSD/cPVL9omra4A3KRK91L33owevXZ81r7qF/a9DdCsVZa90jMGht/V1Ym9sasbmidsJykhULQ==", + "dependencies": { + "proxy-compare": "2.4.0", + "use-context-selector": "1.4.1" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": "*", + "react-native": "*", + "scheduler": ">=0.19.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, "node_modules/react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", @@ -7918,6 +7947,25 @@ } } }, + "node_modules/use-context-selector": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/use-context-selector/-/use-context-selector-1.4.1.tgz", + "integrity": "sha512-Io2ArvcRO+6MWIhkdfMFt+WKQX+Vb++W8DS2l03z/Vw/rz3BclKpM0ynr4LYGyU85Eke+Yx5oIhTY++QR0ZDoA==", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": "*", + "react-native": "*", + "scheduler": ">=0.19.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, "node_modules/use-sidecar": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", diff --git a/web/package.json b/web/package.json index 5250d3740..953a4b875 100644 --- a/web/package.json +++ b/web/package.json @@ -51,6 +51,7 @@ "react-hook-form": "^7.48.2", "react-icons": "^4.12.0", "react-router-dom": "^6.20.1", + "react-tracked": "^1.7.11", "react-transition-group": "^4.4.5", "react-use-websocket": "^4.5.0", "recoil": "^0.7.7", diff --git a/web/src/api/ws.tsx b/web/src/api/ws.tsx index 88091a915..5c01e0341 100644 --- a/web/src/api/ws.tsx +++ b/web/src/api/ws.tsx @@ -1,149 +1,104 @@ import { baseUrl } from "./baseUrl"; -import { - ReactNode, - createContext, - useCallback, - useContext, - useEffect, - useReducer, -} from "react"; -import { produce, Draft } from "immer"; +import { useCallback, useEffect, useState } from "react"; import useWebSocket, { ReadyState } from "react-use-websocket"; import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateEvent, FrigateReview, ToggleableSetting } from "@/types/ws"; import { FrigateStats } from "@/types/stats"; +import useSWR from "swr"; +import { createContainer } from "react-tracked"; -type ReducerState = { - [topic: string]: { - lastUpdate: number; - payload: any; - retain: boolean; - }; -}; - -type ReducerAction = { +type Update = { topic: string; payload: any; retain: boolean; }; -const initialState: ReducerState = { - _initial_state: { - lastUpdate: 0, - payload: "", - retain: false, - }, +type WsState = { + [topic: string]: any; }; -type WebSocketContextProps = { - state: ReducerState; - readyState: ReadyState; - sendJsonMessage: (message: any) => void; -}; +type useValueReturn = [WsState, (update: Update) => void]; -export const WS = createContext({ - state: initialState, - readyState: ReadyState.CLOSED, - sendJsonMessage: () => {}, -}); +function useValue(): useValueReturn { + // basic config + const { data: config } = useSWR("config", { + revalidateOnFocus: false, + }); + const wsUrl = `${baseUrl.replace(/^http/, "ws")}ws`; -export const useWebSocketContext = (): WebSocketContextProps => { - const context = useContext(WS); - if (!context) { - throw new Error( - "useWebSocketContext must be used within a WebSocketProvider" - ); - } - return context; -}; + // main state + const [wsState, setWsState] = useState({}); -function reducer(state: ReducerState, action: ReducerAction): ReducerState { - switch (action.topic) { - default: - return produce(state, (draftState: Draft) => { - let parsedPayload = action.payload; - try { - parsedPayload = action.payload && JSON.parse(action.payload); - } catch (e) {} - draftState[action.topic] = { - lastUpdate: Date.now(), - payload: parsedPayload, - retain: action.retain, - }; - }); - } -} + useEffect(() => { + if (!config) { + return; + } -type WsProviderType = { - config: FrigateConfig; - children: ReactNode; - wsUrl?: string; -}; + const cameraStates: WsState = {}; -export function WsProvider({ - config, - children, - wsUrl = `${baseUrl.replace(/^http/, "ws")}ws`, -}: WsProviderType) { - const [state, dispatch] = useReducer(reducer, initialState); + Object.keys(config.cameras).forEach((camera) => { + const { name, record, detect, snapshots, audio } = config.cameras[camera]; + cameraStates[`${name}/recordings/state`] = record.enabled ? "ON" : "OFF"; + cameraStates[`${name}/detect/state`] = detect.enabled ? "ON" : "OFF"; + cameraStates[`${name}/snapshots/state`] = snapshots.enabled + ? "ON" + : "OFF"; + cameraStates[`${name}/audio/state`] = audio.enabled ? "ON" : "OFF"; + }); + setWsState({ ...wsState, ...cameraStates }); + }, [config]); + + // ws handler const { sendJsonMessage, readyState } = useWebSocket(wsUrl, { onMessage: (event) => { - dispatch(JSON.parse(event.data)); + const data: Update = JSON.parse(event.data); + + if (data) { + setWsState({ ...wsState, [data.topic]: data.payload }); + } }, - onOpen: () => dispatch({ topic: "", payload: "", retain: false }), + onOpen: () => {}, shouldReconnect: () => true, }); - useEffect(() => { - Object.keys(config.cameras).forEach((camera) => { - const { name, record, detect, snapshots, audio } = config.cameras[camera]; - dispatch({ - topic: `${name}/recordings/state`, - payload: record.enabled ? "ON" : "OFF", - retain: false, - }); - dispatch({ - topic: `${name}/detect/state`, - payload: detect.enabled ? "ON" : "OFF", - retain: false, - }); - dispatch({ - topic: `${name}/snapshots/state`, - payload: snapshots.enabled ? "ON" : "OFF", - retain: false, - }); - dispatch({ - topic: `${name}/audio/state`, - payload: audio.enabled ? "ON" : "OFF", - retain: false, - }); - }); - }, [config]); - - return ( - - {children} - - ); -} - -export function useWs(watchTopic: string, publishTopic: string) { - const { state, readyState, sendJsonMessage } = useWebSocketContext(); - - const value = state[watchTopic] || { payload: null }; - - const send = useCallback( - (payload: any, retain = false) => { + const setState = useCallback( + (message: Update) => { if (readyState === ReadyState.OPEN) { sendJsonMessage({ - topic: publishTopic || watchTopic, - payload, - retain, + topic: message.topic, + payload: message.payload, + retain: message.retain, }); } }, - [sendJsonMessage, readyState, watchTopic, publishTopic] + [readyState, sendJsonMessage] + ); + + return [wsState, setState]; +} + +export const { + Provider: WsProvider, + useTrackedState: useWsState, + useUpdate: useWsUpdate, +} = createContainer(useValue, { defaultState: {}, concurrentMode: true }); + +export function useWs(watchTopic: string, publishTopic: string) { + const state = useWsState(); + const sendJsonMessage = useWsUpdate(); + + const value = { payload: state[watchTopic] || null }; + + const send = useCallback( + (payload: any, retain = false) => { + sendJsonMessage({ + topic: publishTopic || watchTopic, + payload, + retain, + }); + }, + [sendJsonMessage, watchTopic, publishTopic] ); return { value, send }; @@ -219,21 +174,21 @@ export function useFrigateEvents(): { payload: FrigateEvent } { const { value: { payload }, } = useWs("events", ""); - return { payload }; + return { payload: JSON.parse(payload) }; } export function useFrigateReviews(): { payload: FrigateReview } { const { value: { payload }, } = useWs("reviews", ""); - return { payload }; + return { payload: JSON.parse(payload) }; } export function useFrigateStats(): { payload: FrigateStats } { const { value: { payload }, } = useWs("stats", ""); - return { payload }; + return { payload: JSON.parse(payload) }; } export function useMotionActivity(camera: string): { payload: string } {