From f0582bf4abe2449789a6c810be523bcc31e2918f Mon Sep 17 00:00:00 2001 From: benya Date: Sun, 8 Mar 2026 20:42:19 +0300 Subject: [PATCH] fix(composer): guard websocket and recorder race on chat switch --- docs/core-checklist-status.md | 2 +- web/src/components/MessageComposer.tsx | 24 +++++++++++++++++++----- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/docs/core-checklist-status.md b/docs/core-checklist-status.md index 64a670b..848dff2 100644 --- a/docs/core-checklist-status.md +++ b/docs/core-checklist-status.md @@ -23,7 +23,7 @@ Legend: 14. Delivery Status - `DONE` (sent/delivered/read + reconnect reconciliation after backend restarts) 15. Typing Realtime - `DONE` (typing start/stop + recording voice start/stop + recording video start/stop in circle-video send flow) 16. Media & Attachments - `DONE` (upload/preview/download/gallery) -17. Voice Messages - `PARTIAL` (record/send/play/seek + global speed 1x/1.5x/2x; recorder now uses mime fallback + chunked capture; UX still being polished) +17. Voice Messages - `PARTIAL` (record/send/play/seek + global speed 1x/1.5x/2x; recorder uses mime fallback + chunked capture; websocket send/recorder stop race on fast chat switch is guarded; UX still being polished) 18. Circle Video Messages - `PARTIAL` (send/play present, recording UX basic) 19. Stickers - `PARTIAL` (web sticker picker with preset pack + favorites) 20. GIF - `PARTIAL` (web GIF picker with Tenor search + preset fallback + favorites) diff --git a/web/src/components/MessageComposer.tsx b/web/src/components/MessageComposer.tsx index b4292c9..cdf6ea9 100644 --- a/web/src/components/MessageComposer.tsx +++ b/web/src/components/MessageComposer.tsx @@ -264,8 +264,7 @@ export function MessageComposer() { useEffect(() => { const prev = prevActiveChatIdRef.current; if (prev && prev !== activeChatId) { - const ws = getWs(); - ws?.send(JSON.stringify({ event: "typing_stop", payload: { chat_id: prev } })); + sendWsEvent("typing_stop", { chat_id: prev }); typingActiveRef.current = false; if (typingStopTimerRef.current !== null) { window.clearTimeout(typingStopTimerRef.current); @@ -327,6 +326,18 @@ export function MessageComposer() { return wsRef.current; } + function sendWsEvent(eventName: string, payload: Record) { + const ws = getWs(); + if (!ws || ws.readyState !== WebSocket.OPEN) { + return; + } + try { + ws.send(JSON.stringify({ event: eventName, payload })); + } catch { + return; + } + } + function sendRealtimeChatEvent( eventName: | "typing_start" @@ -339,8 +350,7 @@ export function MessageComposer() { if (!activeChatId) { return; } - const ws = getWs(); - ws?.send(JSON.stringify({ event: eventName, payload: { chat_id: activeChatId } })); + sendWsEvent(eventName, { chat_id: activeChatId }); } function emitTypingStopIfActive() { @@ -711,7 +721,11 @@ export function MessageComposer() { pointerCancelArmedRef.current = false; setDragHint("idle"); if (recorderRef.current && recorderRef.current.state !== "inactive") { - recorderRef.current.stop(); + try { + recorderRef.current.stop(); + } catch { + // ignore recorder lifecycle race during quick chat switches/unmount + } } recorderRef.current = null; setRecordingState("idle");