← 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
M components/MobileBubbe.tsx
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 →