GalleryExhibit 04

The effect that stopped what it had just started

A React effect listed the state it produced among its own dependencies, so the speech recogniser started, cancelled itself, restarted, and transcribed almost nothing.

Voice sessionStateAsyncReactuseEffectSpeechRecognitionTypeScript

A live session that listens through the browser's speech-recognition API and can switch language mid-sentence.

What you can check here. A deterministic model of the start / end / restart cycle, run against both dependency arrays. Modelled rather than driven in a real browser — see the test section.

The object

See it happen

Exhibit 04 — BrokenVoice session

useEffect(() => { … }, [isRecognizing, language]) // it sets this

clock0ms
start()1
onend1
words kept0

Transcript

nothing yet
  • 0msuser: pressed Start
  • 0msstart: recognition.start() — lang en-US
  • 0mseffect: effect ran — deps [isRecognizing, language]
  • 0msstop: recognition.stop()
  • 0msend: onend fired

There is no microphone here. The panel runs a deterministic model of the recogniser's start / end / onend-restart cycle against the two dependency arrays, on a virtual clock you can step by hand. The 180ms tick is the model's, chosen to match the interval the loop ran at.

What you are looking at

Every successful start immediately stops itself, about six times a second.

  • Press Start and watch the engine ping-pong between starting and ending.
  • Read the transcript: fragments arrive and are cut off mid-word.
  • Step tick by tick to see which line re-arms the effect.

Wall text

What happened

A live SpeechRecognition object ignores changes to its lang property. Switching language therefore has to bounce the recogniser: set the new language, call stop(), and let the onend handler start it again.

The effect that did this listed isRecognizing in its dependency array alongside language. That looks harmless — the effect reads isRecognizing to decide whether there is anything to stop.

But isRecognizing is the state the effect produces. Starting the recogniser set it to true, which re-ran the effect, which called stop(), which fired onend, which restarted the recogniser, which set isRecognizing true again. The engine ping-ponged roughly every 180ms and transcribed almost nothing — a stream of two-word fragments where a sentence should have been.

It was found while chasing something else entirely: a pass over the session's render hot path, looking for wasted work. An empty transcript had been read as “the browser engine is bad at this language”.

Root cause

A dependency array is a statement about what an effect *reacts to*, not about what it *reads*. isRecognizing was in the array because the body mentioned it, which is the habit the lint rule teaches, and it happened to be the one value the effect itself caused to change.

The corrected version keys on language alone and keeps the last applied language in a ref. The ref is what makes the guard honest: the effect can re-run for any reason, and it only bounces the engine when the language it is looking at is genuinely different from the one currently applied.

There is a second guard for the same reason. A stop issued while the session is being torn down must not be answered by onend starting it again, so the restart is gated on a ref rather than on whatever the closure happened to capture.

The change

Code

The dependency array, and the ref that makes it safeminimal reproduction
+  const appliedLanguageRef = useRef(language)
   useEffect(() => {
     const recognition = recognitionRef.current
-    if (!recognition) {
+    if (!recognition) {
+      appliedLanguageRef.current = language
       return
     }
     recognition.lang = language
 
-    if (isRecognizing) {
-      try {
-        recognition.stop()
-      } catch {
-        // No-op: recognition may already be in transition.
-      }
+    if (appliedLanguageRef.current === language) {
+      return
     }
-  }, [isRecognizing, language])
+    appliedLanguageRef.current = language
+    if (!shouldRestartRef.current) {
+      return
+    }
+    try {
+      recognition.stop()
+    } catch {
+      // No-op: recognition may already be in transition.
+    }
+  }, [language])
The comment worth leaving at the sceneminimal reproduction
// A live SpeechRecognition ignores `lang` changes, so switching locale
// has to bounce it — onend then restarts it with the new language.
// This must key off the language ALONE: including isRecognizing made
// every successful start immediately stop itself, and onend's restart
// re-triggered the effect, so the engine ping-ponged every ~180ms and
// transcribed almost nothing.

The test that catches it

Proof it stays fixed

The speech-recognition API does not exist in a Node test environment, so a test that drives the real hook would need a shim elaborate enough to be its own source of bugs. This is the exhibit where being straight about the limits of the evidence matters most.

What the museum tests instead is the mechanism, isolated: lib/sims/restart-loop.ts models the engine's start / end / restart-on-end cycle on a virtual 180ms tick, and runs it against both dependency arrays. That is a model, not the browser — but it is a model of the exact loop, and it is deterministic, so the difference between the two versions is a number rather than an impression.

Eight cases. The broken version is asserted to produce more than four starts and more than four stops in twelve ticks and to commit no words at all; the fixed version is asserted to start exactly once, stop zero times, and still bounce the engine exactly once for a real language change. The negative assertions are the point: without them the tests would pass against either version.

This is weaker evidence than the exhibits whose behaviour is driven end to end in a browser, and the exhibit says so rather than implying otherwise.

tests/unit/sims/restart-loop.test.tsfrom this repository · source
it("ping-pongs between start and end", () => {
  const run = runRecogniser("broken", { ticks: 12 });
 
  expect(run.starts).toBeGreaterThan(4);
  expect(run.ends).toBeGreaterThan(4);
  expect(countOf(run.events, "stop")).toBeGreaterThan(4);
});
 
it("starts once and stays running", () => {
  const run = runRecogniser("fixed", { ticks: 12 });
 
  expect(run.starts).toBe(1);
  expect(run.ends).toBe(0);
  expect(countOf(run.events, "stop")).toBe(0);
});

How it went

Discovery to regression test

  1. Observed

    The transcript was nearly empty

    Noticed during a pass over the session's render hot path. The engine produced two-word fragments; the working theory had been that the browser engine was simply poor at the language.

    The simulation
  2. Final fix

    Key on the language alone

    Drop the produced state from the dependency array and track the applied language in a ref, so the engine is bounced only for a real language change.

    lib/sims/restart-loop.ts
  3. Pinned by a test

    Modelled, and labelled as a model

    The browser API is absent from the test environment, so the cycle is modelled deterministically instead. Eight cases pin the loop in both directions; the exhibit does not claim more than that.

    tests/unit/sims/restart-loop.test.ts

Verify it yourself

Sources