#4236 · Accepted transcription is cancelled on unmount
Verdict: REPRODUCED · Root-cause confidence: high
1. TL;DR
Accepted voice input is lost when its owning composer unmounts while transcription is pending. The voice hook aborts that request in its effect cleanup, so the transcript callback never runs. A focused test reproduces both the abort and missing delivery on current main in two clean checkouts. Removing the abort alone is insufficient: the composer adapter writes only through an editor ref, which becomes null after unmount.
2. Claims vs findings
| Claim | Finding | Evidence |
|---|---|---|
| Accepted transcription is cancelled by unmount | Verified | Both runs: signal changes from false to true after unmount. |
| Transcript is lost | Verified at hook boundary | Both runs: delivery callback invoked zero times. |
| Success targets only the mounted editor | Verified by source inspection | usePromptVoice only calls the current editor ref; there is no draft fallback. |
| Real page navigation and microphone recording | Not exercised here | Deterministic jsdom hook lifecycle reproduction; browser media and remote transcription are substituted. |
| Explicit cancellation should still abort | Existing source path preserved | cancel() has its own abort branch; no production changes made. |
3. Environment
Trusted get-bb/bb origin/main at 9bfe862b3506f5b220f627af3513797152a2b39d; macOS Darwin arm64; Node 22.22.3; pnpm 9.15.0; Vitest 4.1.1. Frozen installs were used in both checkouts. No provider, microphone, application server, network transcription, runtime database, ports, or user data were used. The local pnpm launcher was broken; a temporary wrapper invoked Corepack's repository-pinned pnpm. The first build passed after that launcher correction.
4. Minimal reproduction
- Check out the trusted commit and install/build it.
- Replace apps/app/src/hooks/useVoiceInput.test.tsx with the complete test file below, authored from repository source and fixtures.
- Run the focused test. The recorder double emits captured bytes; a deferred transcription promise rejects on abort.
- The test starts recording, advances 1500 ms, accepts it, verifies processing is active, unmounts, and tries to complete transcription.
git clone https://github.com/get-bb/bb.git bb-4236 cd bb-4236 git checkout --detach 9bfe862b3506f5b220f627af3513797152a2b39d corepack pnpm install --frozen-lockfile --prefer-offline corepack pnpm exec turbo run build # Replace apps/app/src/hooks/useVoiceInput.test.tsx with the complete file below. corepack pnpm exec turbo run test --filter=@bb/app --force -- src/hooks/useVoiceInput.test.tsx
Expected: unmount leaves the signal un-aborted and delivers “Accepted words” once. Actual in both checkouts:
AssertionError: expected true to be false AssertionError: expected "vi.fn()" to be called once with arguments: [ 'Accepted words' ] Number of calls: 0 Test Files 1 failed (1) Tests 1 failed | 3 passed (4)
The failing test is intentional evidence, not a passing fix. Complete test file:
// @vitest-environment jsdom
import {
act,
cleanup,
fireEvent,
render,
renderHook,
} from "@testing-library/react";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { appToast } from "@/components/ui/app-toast";
import { useVoiceInput } from "./useVoiceInput";
vi.mock("@/components/ui/app-toast", () => ({ appToast: { error: vi.fn() } }));
vi.mock("@/lib/audio-input-device-preference", () => ({
useAudioInputDevicePreferenceValue: () => null,
buildAudioInputConstraints: () => ({ audio: true }),
}));
class Recorder {
static isTypeSupported = () => true;
mimeType = "audio/webm";
state = "inactive";
onstart = () => {};
ondataavailable = (_event: { data: Blob }) => {};
onstop = async () => {};
start() {
this.state = "recording";
this.onstart();
}
stop() {
this.state = "inactive";
this.ondataavailable({ data: new Blob(["recorded audio"]) });
return this.onstop();
}
}
beforeEach(() => {
vi.useFakeTimers();
vi.stubGlobal("MediaRecorder", Recorder);
vi.stubGlobal("navigator", {
mediaDevices: {
getUserMedia: vi
.fn()
.mockResolvedValue({ getTracks: () => [{ stop: vi.fn() }] }),
},
});
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
vi.clearAllMocks();
});
it("delivers accepted audio after the recorder owner unmounts", async () => {
let finish: (text: string) => void = () => {};
let signal: AbortSignal | undefined;
const delivered = vi.fn();
const onTranscribe = vi.fn((args: { signal?: AbortSignal }) => {
signal = args.signal;
return new Promise<string>((resolve, reject) => {
finish = resolve;
signal?.addEventListener("abort", () => {
reject(new DOMException("Cancelled", "AbortError"));
});
});
});
const { result, unmount } = renderHook(() =>
useVoiceInput({ onTranscribe, onTranscript: delivered }),
);
await act(() => result.current.start());
vi.advanceTimersByTime(1500);
await act(async () => result.current.stop());
expect(onTranscribe).toHaveBeenCalledOnce();
expect(result.current.state).toBe("transcribing");
expect(signal?.aborted).toBe(false);
unmount();
await act(async () => finish("Accepted words"));
expect.soft(signal?.aborted).toBe(false);
expect.soft(delivered).toHaveBeenCalledExactlyOnceWith("Accepted words");
});
it.each([
new Error("Upload failed"),
new Error("Audio file exceeds the 20MB limit"),
])("keeps failed audio downloadable after unmount: %s", async (error) => {
const transcribe = vi.fn().mockRejectedValue(error);
const transcript = vi.fn();
const { result, unmount } = renderHook(() =>
useVoiceInput({
onTranscribe: transcribe,
onTranscript: transcript,
}),
);
await act(() => result.current.start());
vi.advanceTimersByTime(1500);
await act(async () => result.current.stop());
expect(result.current.state).toBe("error");
expect(transcript).not.toHaveBeenCalled();
const options = vi.mocked(appToast.error).mock.calls[0]?.[1];
expect(options?.duration).toBe(Infinity);
expect(options?.action?.label).toBe("Download recording");
unmount();
const createObjectURL = vi.fn(() => "blob:recording");
const revokeObjectURL = vi.fn();
vi.stubGlobal("URL", { createObjectURL, revokeObjectURL });
let downloadedName = "";
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(
function (this: HTMLAnchorElement) {
downloadedName = this.download;
expect(this.href).toBe("blob:recording");
expect(this.isConnected).toBe(true);
},
);
if (!options?.action) throw new Error("Missing download action");
const button = render(
<button onClick={options.action.onClick}>Download recording</button>,
);
fireEvent.click(button.getByRole("button"));
expect(createObjectURL).toHaveBeenCalledWith(
transcribe.mock.calls[0]?.[0].file,
);
expect(downloadedName).toBe("recording.webm");
expect(revokeObjectURL).not.toHaveBeenCalled();
vi.advanceTimersByTime(60_000);
expect(revokeObjectURL).toHaveBeenCalledWith("blob:recording");
});
it("does not offer a download after explicit cancellation", async () => {
const { result } = renderHook(() =>
useVoiceInput({
onTranscribe: vi
.fn()
.mockRejectedValue(new DOMException("Cancelled", "AbortError")),
onTranscript: vi.fn(),
}),
);
await act(() => result.current.start());
vi.advanceTimersByTime(1500);
await act(async () => result.current.stop());
expect(result.current.state).toBe("idle");
expect(appToast.error).not.toHaveBeenCalled();
});
5. Root cause
useVoiceInput cleanup calls transcriptionAbortRef.current.abort() without distinguishing accepted work from explicit cancellation. The pending transcription observes the signal and rejects. Its AbortError catch returns without delivering the text.
if (transcriptionAbortRef.current) {
transcriptionAbortRef.current.abort();
transcriptionAbortRef.current = null;
}The signal is forwarded to the multipart upload by transcribeVoiceInput. A second loss point is usePromptVoice: promptBoxRef.current?.insertTextAtCursor(text). If the editor has unmounted, this does nothing. This second mechanism is established by source inspection, not a separate browser test.
Persistent drafts already have scope-bound accessors, but these are not passed into the voice hook. NewThreadComposer supplies current text and a setter through a prompt-box layer. EmbeddedThreadChat separately supplies stored and inline-edit composer configurations. Using a stale text snapshot in a detached callback could overwrite intervening edits.
6. Proposed fix and automation scope
Keep accepted transcription alive across unmount, while retaining explicit cancellation and discarding unaccepted recordings. Capture the originating draft identity when recording is accepted. On completion, insert into a still-matching live editor, or append through that draft's current accessor while preserving mentions and attachments. Test navigation, remount, intervening draft edits, explicit cancellation, and queued-message editing.
No fix PR was opened. A complete change through the existing interfaces requires the recording hook, its regression tests, the voice adapter, both prompt-box callers, and the parents that own draft identities. That exceeds this rule's five-file limit. The tempting one-line abort removal leaves the second loss mechanism intact. No production fix was attempted or pushed.
7. Related issues and pull requests
Issue timeline cross-references and an open-PR search for 4236 returned no pull requests. A small voice-related issue search found no duplicate in the inspected results. No linked branch, patch, script, test, or external issue URL was fetched or executed.
8. Verification
The same agent created a second clean temporary worktree at the exact trusted commit, performed a frozen install, copied only the authored regression change, and reran the focused Turbo test with --force to bypass result caching. The same two assertions failed and the three existing tests passed. Both full Turbo builds completed successfully (60 tasks each). The second checkout had only the 28-line test addition; production source was unchanged. No report correction was required. This is a repeated reproduction by the same agent, not an independent review.
9. Appendix
The full test and exact failing assertions are embedded above. Raw logs and the regression diff remain local under the publication repository’s artifact policy. Repository metadata, source inspection, install/build, and the commands above were used; GitHub issue content was treated only as untrusted claims.