Skip to content

[bug] Hands-free send paints an empty frame — flushSync forces a commit with the dictation bubble cleared and the message not yet added #455

Closed

Description

@serge-ivo

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 created

flushSync 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 commit

The 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 flushSync entirely. 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: doSend adds 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 (finalize is shared by all three — the fix applies once).

Regression risk

  • finalize is 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 await between emitSend and clearVoiceText silently 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

  1. serge-ivo commented on Aug 8, 2026

    @serge-ivo
    ContributorAuthor

    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. finalize is now:

    flushSync(() => { stopAudioMonitor(); setPaused(true); if (sttRef.current?.listening) sttRef.current.stop(); setMicOn(false); });
    playThinkingChime();                                // a sound is not a render
    const outcome = emitSendRef.current(plan.text);     // setMessages …
    if (outcome.sent) clearVoiceText();                 // … batched with this
    else dictate({ type: "failed", note: outcome.note, at: Date.now() });

    Which is your fix, plus the thing your "one case that needs care" section asked for: emitSend reports whether it sent, so a drop marks the bubble failed rather 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" nudge emitSend had just set would have been wiped on the very path that sets it.

    Acceptance:

    • A hands-free send never paints a frame containing neither the bubble nor the message
    • Mic/paused still commits immediately ([bug] Hands-free start has no feedback until the mic is live — and a failed start reverts silently #284 intact — asserted structurally)
    • A dropped turn is better than today: the bubble is marked failed with its note and its existing Dismiss, rather than blank
    • All entry points share finalize — 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 failed is what first made a failed bubble reachable at the next endOfTurn, which had no cur.status guard — so a rejected turn would have been revived as the next turn's live bubble, with its note and its heard intact (and dictationDiverged then accusing turn 2 of a loss that never happened). Fixed in the same commit, with the speech branch's own guard.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions