diff --git a/web/src/components/config-form/theme/fields/DictAsYamlField.tsx b/web/src/components/config-form/theme/fields/DictAsYamlField.tsx index 0b21f9b1a..ff1145cfc 100644 --- a/web/src/components/config-form/theme/fields/DictAsYamlField.tsx +++ b/web/src/components/config-form/theme/fields/DictAsYamlField.tsx @@ -2,7 +2,7 @@ import type { FieldPathList, FieldProps } from "@rjsf/utils"; import yaml from "js-yaml"; import { Textarea } from "@/components/ui/textarea"; import { cn } from "@/lib/utils"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; function formatYaml(value: unknown): string { if ( @@ -54,10 +54,14 @@ export function DictAsYamlField(props: FieldProps) { const [text, setText] = useState(() => formatYaml(formData)); const [error, setError] = useState(); + const focusedRef = useRef(false); useEffect(() => { - setText(formatYaml(formData)); - setError(undefined); + // Only sync from external formData changes, not our own onChange + if (!focusedRef.current) { + setText(formatYaml(formData)); + setError(undefined); + } }, [formData]); const handleChange = useCallback( @@ -73,8 +77,13 @@ export function DictAsYamlField(props: FieldProps) { [onChange, fieldPath], ); + const handleFocus = useCallback(() => { + focusedRef.current = true; + }, []); + const handleBlur = useCallback( (_e: React.FocusEvent) => { + focusedRef.current = false; // Reformat on blur if valid const { value } = parseYaml(text); if (value !== undefined) { @@ -101,6 +110,7 @@ export function DictAsYamlField(props: FieldProps) { placeholder={"key: value"} rows={Math.max(3, text.split("\n").length + 1)} onChange={handleChange} + onFocus={handleFocus} onBlur={handleBlur} /> {error &&

{error}

} diff --git a/web/src/components/player/LivePlayer.tsx b/web/src/components/player/LivePlayer.tsx index 2e37f41a4..0ab703352 100644 --- a/web/src/components/player/LivePlayer.tsx +++ b/web/src/components/player/LivePlayer.tsx @@ -195,6 +195,7 @@ export default function LivePlayer({ }, [preferredLiveMode]); const [key, setKey] = useState(0); + const prevStreamNameRef = useRef(streamName); const resetPlayer = () => { setLiveReady(false); @@ -202,8 +203,11 @@ export default function LivePlayer({ }; useEffect(() => { - if (streamName) { - resetPlayer(); + if (prevStreamNameRef.current !== streamName) { + prevStreamNameRef.current = streamName; + if (streamName) { + resetPlayer(); + } } }, [streamName]); diff --git a/web/src/components/player/MsePlayer.tsx b/web/src/components/player/MsePlayer.tsx index 1a2b1b6cb..e8daed443 100644 --- a/web/src/components/player/MsePlayer.tsx +++ b/web/src/components/player/MsePlayer.tsx @@ -81,6 +81,7 @@ function MSEPlayer({ const wsRef = useRef(null); const reconnectTIDRef = useRef(null); const intentionalDisconnectRef = useRef(false); + const onCloseRef = useRef<(() => void) | null>(null); const ondataRef = useRef<((data: ArrayBufferLike) => void) | null>(null); const onmessageRef = useRef<{ [key: string]: (msg: { value: string; type: string }) => void; @@ -167,6 +168,8 @@ function MSEPlayer({ wsRef.current = new WebSocket(wsURL); wsRef.current.binaryType = "arraybuffer"; wsRef.current.addEventListener("open", onOpen); + // Capture current onClose identity so removeEventListener can find it later + onCloseRef.current = onClose; wsRef.current.addEventListener("close", onClose); // we know that these deps are correct // eslint-disable-next-line react-hooks/exhaustive-deps @@ -200,20 +203,23 @@ function MSEPlayer({ intentionalDisconnectRef.current = true; setWsState(WebSocket.CLOSED); - // Remove event listeners to prevent them firing during close + // Remove event listeners to prevent them firing during close. + // Use onCloseRef to remove the exact function that was attached in onConnect, + // since onClose may have been recreated by React since then. try { ws.removeEventListener("open", onOpen); - ws.removeEventListener("close", onClose); + if (onCloseRef.current) { + ws.removeEventListener("close", onCloseRef.current); + onCloseRef.current = null; + } } catch { // Ignore errors removing listeners } - // Only call close() if the socket is OPEN or CLOSING - // For CONNECTING or CLOSED sockets, just let it die - if ( - currentReadyState === WebSocket.OPEN || - currentReadyState === WebSocket.CLOSING - ) { + // Close the socket in any non-CLOSED state, including CONNECTING. + // A CONNECTING socket that is not closed will complete its handshake + // and remain open, leaking a browser connection. + if (currentReadyState !== WebSocket.CLOSED) { try { ws.close(); } catch {