Repository navigation
Description
flushSync forces a paint of the frame where the dictation bubble is gone and the message has not arrived
Reported: dictating hands-free, the pending bubble briefly vanishes before the real message appears — a visible gap with nothing on screen.
This is not a race or a timing fluke. It is guaranteed by a flushSync, and it happens on every hands-free send.
The code
packages/sdk/src/voice/use-voice.ts:1186-1194 — the end of finalize:
flushSync(() => {
clearVoiceText(); // ← dictation → null. The bubble is removed.
stopAudioMonitor();
setPaused(true);
if (sttRef.current?.listening) sttRef.current.stop();
setMicOn(false);
playThinkingChime();
});
emitSendRef.current(plan.text); // ← only now is the real message createdflushSync does exactly what it says: React commits and paints synchronously, right there, in a state where dictation === null and the user's message is not yet in messages. That painted frame is the gap.
Then, still synchronously, emitSend does real work before the message exists — dictationDiverged(heard, text) (word-splitting two strings), a localStorage.getItem on the debug path, planSend(...) (noise check, language check, vocabulary correction), crypto.randomUUID() — and only then calls onSendRef.current(...) → doSend → setMessages (store/console/src/pages/InstanceDetail.tsx:642), which is a normal batched update painting on the next frame.
So the gap is one forced paint, plus that synchronous work, plus one more frame.
Why this is a misapplied idiom, not a missing await
The file uses flushSync in 30 places, and the rationale is stated once, correctly, at :1564-1568:
"Visible on THIS frame. Everything below flips only after two awaits (the config read and
getUserMedia…) … flushSync because the awaits follow immediately — a batched update would not paint until after the very wait it exists to cover."
That is the right precondition: an await follows, so batching would defer the paint past the wait.
At :1186 nothing awaits. emitSend is a plain function, and doSend's setMessages (:642) runs before its first await (:650). Everything is in one synchronous task — which means that without flushSync, React 18 would batch clearVoiceText() together with setMessages() into a single commit, and the bubble would be replaced by the message in one paint, with no intermediate state ever rendered.
The flushSync is the sole cause of the flash. It converts an intermediate state that would have been invisible into one that is guaranteed to be painted.
Blame: the block arrived in 56ca1ae (#281/#284) — the change that introduced the pending-utterance bubble and the flushSync idiom in the same commit. The idiom was correct where it was introduced and was carried to a site that does not meet its precondition. (This is the "pattern applied without its precondition" class in #438.)
Fix
Take clearVoiceText() out of the flushSync and run it after emitSend, so it batches with setMessages:
flushSync(() => {
stopAudioMonitor();
setPaused(true);
if (sttRef.current?.listening) sttRef.current.stop();
setMicOn(false);
});
playThinkingChime(); // a sound is not a render — see note below
emitSendRef.current(plan.text); // setMessages …
clearVoiceText(); // … batched with this → ONE commitThe flushSync keeps doing its real job (mic and paused state committed immediately, which is what makes the control feel responsive) while the bubble→message handoff becomes a single atomic paint.
Also worth moving: playThinkingChime() is an audio side effect inside a flushSync callback, which is a render-flush callback. It works, but side effects do not belong there and it muddies what the flush is for.
The one case that needs care
emitSend can decide not to send: planSend returns {action: "drop"} for a noise or language rejection, and returns early without calling onSend (use-voice.ts:~531). With the fix as written, clearVoiceText() still runs afterwards, so the behaviour is unchanged from today — the turn vanishes.
That is already the known #377/#420 tension (a dropped turn erased with the bubble gone). This change must not make it worse, and the natural implementation is to have emitSend report whether it sent, so the caller can clear on a send and mark the bubble failed on a drop. That is the right shape and it converges with #420's fix (5).
Alternatives considered and rejected
- Remove the
flushSyncentirely. Tempting, and it would fix the flash. Rejected: the mic/paused half genuinely wants an immediate commit — that is the [bug] Hands-free start has no feedback until the mic is live — and a failed start reverts silently #284 responsiveness fix — and dropping it risks the "press read as nothing happened" regression that flushSync was introduced to cure. - Keep the bubble until the server confirms the message. Rejected:
doSendadds the message optimistically on purpose, and waiting for the round trip would replace a one-frame gap with a multi-second one. - Cross-fade / animate the handoff. Rejected as the primary fix — it hides a state transition rather than making it atomic, and it would still show an empty frame underneath.
- Render the message before clearing, in two
flushSyncs. Rejected: two forced paints where one batched commit does the job, and it makes the bubble and message coexist for a frame (a duplicate flash instead of a gap — a different artifact, not an improvement).
Acceptance criteria
- A hands-free send never paints a frame containing neither the dictation bubble nor the user's message.
- The mic/paused state still commits immediately on send (no regression of [bug] Hands-free start has no feedback until the mic is live — and a failed start reverts silently #284).
- A dropped turn (noise/language) is no worse than today, and ideally leaves the bubble marked rather than blank.
- Tap-to-talk and the browser-dictation silence-timer path go through the same handoff (
finalizeis shared by all three — the fix applies once).
Regression risk
finalizeis shared by the Whisper path, the silence timer and the stop-word early flush; a change here affects all three. That is an argument for the fix, not against it — one shape, one behaviour — but the test should cover all three entry points.- React batching is what makes the fix work, so anything that later inserts an
awaitbetweenemitSendandclearVoiceTextsilently reintroduces the flash. Worth a comment naming that dependency explicitly, since the whole bug is a precondition that was not written down.
Verification status
Mechanism is read directly from the code and is unambiguous: flushSync commits synchronously by definition, and clearVoiceText() is inside it while setMessages is after it. I have not measured the frame count in a browser — it needs a live microphone and a hands-free turn. The reporter observing the gap is the empirical half; the acceptance criterion above is written so the implementer confirms it.
Related: #281/#284 (the commit that introduced both the bubble and this idiom), #377 and #420 (what happens to a turn that is cleared before it is delivered), #438 (this is an instance of "a pattern applied where its precondition does not hold").
Activity
fix(voice): a hands-free send hands the bubble to the message in one …
serge-ivo commentedon Aug 8, 2026
Already delivered in
8c6f75b, live, and verified against the acceptance list. The issue was left open after the work landed; closing it now rather than re-doing it.I re-read the shipped code rather than trusting the commit message.
finalizeis now:Which is your fix, plus the thing your "one case that needs care" section asked for:
emitSendreports whether it sent, so a drop marks the bubblefailedrather than clearing it. That also fixed a second-order bug the unconditional clear would have introduced —clearVoiceText()also clears the NOTICE, so the "Didn't catch your language" nudgeemitSendhad just set would have been wiped on the very path that sets it.Acceptance:
failedwith its note and its existing Dismiss, rather than blankfinalize— the Whisper path, the browser-dictation silence timer, the stop-word early flush and the max-duration timer. The sharing is asserted, because a fourth entry point emitting its own send would bring the empty frame back on one path only.Tested in the two halves this repo uses for a hook it cannot mount: a model of React's two commit rules graded by "the turn's words are visible in exactly one place at every painted commit" (zero is this bug; two is the artifact the rejected render-then-clear alternative produces), plus structural assertions that no
await/flushSync/setTimeout/queueMicrotask/.then(sits between the emit and the clear — which is the invisible precondition the whole issue is about. Verified red on the pre-fix code: 6 of 18.Not claimed, then or now: a frame count in a real browser. That needs a live microphone and a hands-free turn, exactly as your verification-status section says.
Since it also touched the reducer: marking a drop
failedis what first made afailedbubble reachable at the nextendOfTurn, which had nocur.statusguard — so a rejected turn would have been revived as the next turn's live bubble, with its note and itsheardintact (anddictationDivergedthen accusing turn 2 of a loss that never happened). Fixed in the same commit, with thespeechbranch's own guard.