GalleryExhibit 04
The effect that stopped what it had just started
A React effect listed among its dependencies a state that its own side effect changed, so the speech recogniser started, cancelled itself, restarted, and transcribed almost nothing.
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. It stands in for React and the speech API rather than running them — see the test section.
The object
See it happen
useEffect(() => { … }, [isRecognizing, language]) // it changes this
Transcript
- 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. In the original, the odd two-word fragment still got through; in the model nothing survives long enough to become a word.
What you are looking at
Every successful start immediately stops itself, about six times a second.
- Press Start listening and watch the engine ping-pong between starting and ending.
- Read the transcript and the event log: every fragment is cut off before it becomes a 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 changes because of what the effect does. Starting the recogniser set it to true, which re-ran the effect, which called stop(); onend set it back to false and restarted the recogniser, which set it to true again — and the effect ran once more. 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’s speech recogniser is bad at this language”.
Root cause
Every reactive value an effect reads is a reason for it to run again, so the dependency array has to list it — that is what the lint rule enforces, and the rule was right. The mistake was reading isRecognizing at all: it was the one value the effect’s own side effect changed, so listing it meant every start re-ran the effect.
Leaving it out of the array while still reading it would only have traded the loop for a stale closure. The corrected version stops reading it. It keys on language alone and keeps the last applied language in a ref, which an effect can read without depending on it. 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 ref for a related reason. A stop issued while the session is being torn down must not be answered by onend starting it again, so onend checks a keep-listening ref rather than whatever its closure captured, and the effect checks the same ref before bouncing the engine.
The change
Code
+ const langInUseRef = useRef(lang);
useEffect(() => {
const engine = engineRef.current;
- if (!engine) return;
- engine.lang = lang;
- if (isRecognizing) engine.stop(); // onend restarts it with the new lang
- }, [isRecognizing, lang]);
+ const changed = langInUseRef.current !== lang;
+ langInUseRef.current = lang;
+ if (!engine || !changed) return;
+ engine.lang = lang;
+ // Bounce only a session that is meant to keep running; onend
+ // brings it straight back with the new language.
+ if (keepListeningRef.current) engine.stop();
+ }, [lang]);
// A live recogniser ignores changes to `lang`, so a language switch has
// to stop it and let onend start it again with the new value.
// Key this effect on the language alone, and do not read isRecognizing
// in it: starting the recogniser changes isRecognizing, so reading it
// made every successful start re-run the effect and stop the engine,
// roughly every 180ms, and almost nothing was transcribed.
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.
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. Asserting both directions is the point: each version’s expectations fail against the other, so the suite can tell them apart.
This is weaker evidence than a test that runs the real mechanism, as exhibit 01’s does in the browser, and the exhibit says so rather than implying otherwise.
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
Discovered
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’s speech recogniser was simply poor at the language.
The simulation ↗Final fix
Key on the language alone
Stop reading the produced state, drop it 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 ↗Pinned by a test
Modelled, and labelled as a model
The browser API is absent from the Node test environment, so the cycle is modelled deterministically instead. The tests 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
- Exhibit datacontent/exhibits/speech-restart-loop.ts ↗Every word on this page, as data.
- Simulationcomponents/sims/recogniser/recogniser-sim.tsx ↗The tick strip, the event log and the transcript.
- Logiclib/sims/restart-loop.ts ↗The engine model, and the two dependency arrays.
- Testtests/unit/sims/restart-loop.test.ts ↗Both directions: the loop, and the single deliberate bounce.