Reports

#4236 · Accepted transcription is cancelled on unmount

BugHigh priorityMedium effortuiIssue · 2026-09-24 · base 9bfe862b3506f5b220f627af3513797152a2b39d

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

ClaimFindingEvidence
Accepted transcription is cancelled by unmountVerifiedBoth runs: signal changes from false to true after unmount.
Transcript is lostVerified at hook boundaryBoth runs: delivery callback invoked zero times.
Success targets only the mounted editorVerified by source inspectionusePromptVoice only calls the current editor ref; there is no draft fallback.
Real page navigation and microphone recordingNot exercised hereDeterministic jsdom hook lifecycle reproduction; browser media and remote transcription are substituted.
Explicit cancellation should still abortExisting source path preservedcancel() 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

  1. Check out the trusted commit and install/build it.
  2. Replace apps/app/src/hooks/useVoiceInput.test.tsx with the complete test file below, authored from repository source and fixtures.
  3. Run the focused test. The recorder double emits captured bytes; a deferred transcription promise rejects on abort.
  4. 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.