[object Object]

← back to Dear Bubbe Nextjs

fix(bubbe/mobile): create SpeechRecognition once + latest-ref for onresult

557b99d1c393d1082b2875fa09f07f0be5ae32e8 · 2026-07-17 07:44:32 -0700 · Steve Abrams

The mic useEffect recreated a SpeechRecognition instance on every isSpeaking/
isLoading change (pre-existing since d6aa0de). Now created once; the once-bound
onresult calls handleSendRef.current so it always uses the freshest
handleSendMessage (session/mode closure) instead of the initial render's.
Desktop left as-is (its recreate is load-bearing for auto-restart).

Text chat + build verified green (WebKit); voice needs a real-device test.

Files touched

Diff

commit 557b99d1c393d1082b2875fa09f07f0be5ae32e8
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Jul 17 07:44:32 2026 -0700

    fix(bubbe/mobile): create SpeechRecognition once + latest-ref for onresult
    
    The mic useEffect recreated a SpeechRecognition instance on every isSpeaking/
    isLoading change (pre-existing since d6aa0de). Now created once; the once-bound
    onresult calls handleSendRef.current so it always uses the freshest
    handleSendMessage (session/mode closure) instead of the initial render's.
    Desktop left as-is (its recreate is load-bearing for auto-restart).
    
    Text chat + build verified green (WebKit); voice needs a real-device test.
---
 components/MobileBubbe.tsx | 11 +++++++++--
 1 file changed, 9 insertions(+), 2 deletions(-)

diff --git a/components/MobileBubbe.tsx b/components/MobileBubbe.tsx
index cdbecf4..d1638e1 100644
--- a/components/MobileBubbe.tsx
+++ b/components/MobileBubbe.tsx
@@ -18,6 +18,7 @@ export default function MobileBubbe() {
   const [transitionTimer, setTransitionTimer] = useState<NodeJS.Timeout | null>(null)
   const messagesEndRef = useRef<HTMLDivElement>(null)
   const recognitionRef = useRef<WebSpeechRecognition | null>(null)
+  const handleSendRef = useRef<(t: string) => void>(() => {}) // latest handleSendMessage for the once-created recognition
   const audioRef = useRef<HTMLAudioElement | null>(null)
   const isSpeakingRef = useRef(false) // synchronous lock so voices never overlap
   const isAutoStartedRef = useRef(false)
@@ -31,7 +32,9 @@ export default function MobileBubbe() {
     if (typeof window !== 'undefined' && ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window)) {
       const SpeechRecognition = window.webkitSpeechRecognition || window.SpeechRecognition
       if (!SpeechRecognition) return
-      try {
+      // Create the recognition instance ONCE (was recreated on every state
+      // change → leaked instances). Handlers read fresh state via handleSendRef.
+      if (!recognitionRef.current) try {
         recognitionRef.current = new SpeechRecognition()
         recognitionRef.current.continuous = false
         recognitionRef.current.interimResults = false
@@ -39,7 +42,7 @@ export default function MobileBubbe() {
 
         recognitionRef.current.onresult = (event: any) => {
           const transcript = event.results[0][0].transcript
-          handleSendMessage(transcript)
+          handleSendRef.current(transcript)
           setIsListening(false)
           // Smooth UI transition
           if (transitionTimer) clearTimeout(transitionTimer)
@@ -138,6 +141,10 @@ export default function MobileBubbe() {
     }
   }
 
+  // Keep the once-created recognition's onresult pointed at the latest
+  // handleSendMessage (fresh session/mode closure), no dep array = every render.
+  useEffect(() => { handleSendRef.current = handleSendMessage })
+
   const startListening = () => {
     if (!recognitionRef.current) {
       alert('Voice not supported on this device. Please type instead.')

← 3899565 refactor(bubbe): delete dead ExpandableChat + VoiceManager  ·  back to Dear Bubbe Nextjs  ·  chore: v0.2.0 (session close) — lint clean, refactor done + 35d9212 →