DEV Community

babycat
babycat

Posted on

The Stop Button Lied: Debugging a Cancelled Stream That Never Released the Composer

I had VoiceOver running in Safari, a chat transcript filling in, and both hands on the keyboard. The assistant was still streaming tokens when I pressed Escape, which should have been the cancel shortcut for that turn. The bubble froze mid-sentence, the spinner vanished, and I honestly thought the request was dead. Then I pressed Tab and found the composer still disabled, the live region still saying generating, and the network panel still transferring bytes.

Was the interface cancelled, or had I only painted a cancelled costume over a live request? That silent disagreement is the failure I want to debug with you, because mouse users rarely notice it. They click Stop, watch the spinner die, and assume the turn is over. Keyboard and screen-reader users get a different product: focus is trapped, status is stale, and the next prompt never reaches the textarea.

The interaction that actually failed

I reproduced this in a single-file chat demo rather than in a private production app I cannot show. The stream came from a remote chunked HTTP endpoint, which matters because local mocks hide abort races. I watched three surfaces at once: the visible controls, the accessibility tree, and the network panel. Those three surfaces disagreed, and that disagreement is the bug, not a missing ARIA label.

Here is the table I wrote while the UI still looked stopped:

Surface After Escape What it should be
Visible spinner hidden hidden
Composer textarea disabled enabled and focused
Stop button still in tab order removed or inert, not focused
aria-live status “Generating response” “Generation cancelled”, once
Fetch / SSE still transferring aborted, then idle
Assistant message status: streaming status: cancelled, partial text kept

If your screenshot looks cancelled and your accessibility tree still says generating, you do not have a visual polish issue. You have a state-machine bug with an accessibility amplifier sitting on top of it. Have you ever tabbed into a Stop control that no longer stops anything? That is this failure wearing a button.

How I instrumented the failure

I did not start by restyling the Stop button, because the spinner was already gone. I asked a simpler question instead: which layer still believed the turn was live? That question keeps you from “fixing” paint while the composer stays disabled for the next prompt.

I used this checklist, in this order:

  1. Log every status transition with timestamps, including a real cancelling state.
  2. Log document.activeElement after Escape, after abort, and after the last chunk.
  3. Watch the Network panel for (canceled) versus a stream that keeps transferring.
  4. Inspect the live region with the accessibility inspector, not with your eyes.
  5. Press Tab and Shift+Tab through the transcript, Stop, Retry, and composer.

In the page I traced focus and status together, because either log alone lies:

function traceUi(tag, status) {
  const active = document.activeElement;
  console.log(tag, {
    status,
    activeId: active && active.id,
    activeRole: active && active.getAttribute("role"),
    liveText: document.getElementById("gen-status")?.textContent,
  });
}
Enter fullscreen mode Exit fullscreen mode

The log told the real story faster than another screenshot would have. Escape flipped a local stopping flag, but fetch never received an AbortSignal. A late chunk arrived, React set status back to streaming, and the composer disabled itself again. Have you watched a cancelled request resurrect a spinner after you already moved on? That resurrection is the same bug in a different shirt.

Root cause, stacked in three layers

Three independent mistakes stacked, and any one of them can trap a keyboard user. I am listing them as a debugging order, not as a component library lecture. Fix the abort first, then the boolean, then the announcement.

  1. Cancel was a CSS hide, not an abort. Stop set showSpinner = false and left read() running on the response body.
  2. The composer keyed off a boolean. disabled={isStreaming || isBusy} treated cancelling as busy forever when abort never resolved.
  3. The live region was sticky. It announced “Generating” on start and never announced a terminal state.

A fourth issue showed up only against a real remote stream, which mocked timers never reproduce. Client abort does not always stop the server from generating tokens. If you ignore late chunks instead of closing the turn, a cancelled bubble can still mutate under the reader. Should a cancelled message keep growing after the user has moved back to the composer? No, and your turn id should refuse those writes.

The state table I wish I had drawn first

Treat the turn as a typed machine, not a pile of booleans fighting each other. Booleans are how isStreaming && !isError && !isCancelling becomes an untestable knot. Draw legal edges before you write the button handler, then make illegal edges loud in development.

type TurnStatus =
  | "idle"
  | "streaming"
  | "cancelling"
  | "cancelled"
  | "complete"
  | "error";

type Turn = {
  id: string;
  status: TurnStatus;
  text: string;
  error?: string;
};

const LEGAL: Record<TurnStatus, TurnStatus[]> = {
  idle: ["streaming"],
  streaming: ["cancelling", "complete", "error"],
  cancelling: ["cancelled", "error"],
  cancelled: ["idle"],
  complete: ["idle"],
  error: ["idle"],
};

function transition(turn: Turn, next: TurnStatus): Turn {
  if (!LEGAL[turn.status].includes(next)) {
    throw new Error(`illegal ${turn.status} -> ${next}`);
  }
  return { ...turn, status: next };
}
Enter fullscreen mode Exit fullscreen mode

Why throw on a UI path during development? Because an illegal transition is the bug you are hunting, and silent coercion will hide it again. In production, log the illegal event and ignore it, especially late SSE chunks after cancelled. The machine is the test surface. The Stop button is only a pointer into that machine.

Expected UI states after the machine exists

Write the states as a contract you can click through without a mouse. If a state cannot be reached from the keyboard, it does not exist for this interface. I keep this list next to the component because screenshots hide focus.

  • idle: composer enabled and focused; no Stop; live region quiet.
  • streaming: composer disabled; Stop visible and reachable; status announced once.
  • cancelling: Stop inert or gone; composer still disabled; status says cancelling once.
  • cancelled: partial text kept; composer enabled and focused; Stop gone.
  • complete / error: terminal announcement once; composer restored; retry is a new turn.

Pointer-independent actions matter here as much as roles do. Escape must cancel while streaming, and Enter must submit only when the composer is actually enabled. If Stop is a <div onClick>, you have already failed the keyboard path before abort enters the chat.

The cancel path that restores the composer

Here is a proposed single-file control flow, not a claim about a shipped product. Wire your own endpoint in startStream, then try Escape before the first byte and again mid-chunk. The point is the turn id, the signal, and the focus restore, not the styling.

import { useEffect, useId, useRef, useState } from "react";

type TurnStatus =
  | "idle"
  | "streaming"
  | "cancelling"
  | "cancelled"
  | "complete"
  | "error";

export function CancellableComposer() {
  const composerId = useId();
  const composerRef = useRef<HTMLTextAreaElement>(null);
  const abortRef = useRef<AbortController | null>(null);
  const turnIdRef = useRef(0);
  const [status, setStatus] = useState<TurnStatus>("idle");
  const [prompt, setPrompt] = useState("");
  const [text, setText] = useState("");
  const [announcement, setAnnouncement] = useState("Idle");

  function focusComposer() {
    composerRef.current?.focus();
  }

  async function startStream(value: string) {
    const turnId = ++turnIdRef.current;
    const abort = new AbortController();
    abortRef.current = abort;
    setText("");
    setStatus("streaming");
    setAnnouncement("Generating response");

    try {
      const res = await fetch("/stream", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ prompt: value }),
        signal: abort.signal,
      });
      if (!res.body) throw new Error("No response body");
      const reader = res.body.getReader();
      const decoder = new TextDecoder();

      while (true) {
        const { value: chunk, done } = await reader.read();
        if (turnId !== turnIdRef.current) return;
        if (done) break;
        setText((prev) => prev + decoder.decode(chunk, { stream: true }));
      }

      if (turnId !== turnIdRef.current) return;
      setStatus("complete");
      setAnnouncement("Response complete");
    } catch (err) {
      if (turnId !== turnIdRef.current) return;
      if (abort.signal.aborted) {
        setStatus("cancelled");
        setAnnouncement("Generation cancelled");
      } else {
        setStatus("error");
        setAnnouncement("Generation failed");
      }
    } finally {
      if (abortRef.current === abort) abortRef.current = null;
      if (turnId === turnIdRef.current) {
        queueMicrotask(() => focusComposer());
      }
    }
  }

  function cancel() {
    if (status !== "streaming") return;
    setStatus("cancelling");
    setAnnouncement("Cancelling");
    abortRef.current?.abort();
  }

  useEffect(() => {
    function onKey(event: KeyboardEvent) {
      if (event.key === "Escape" && status === "streaming") {
        event.preventDefault();
        cancel();
      }
    }
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [status]);

  const busy = status === "streaming" || status === "cancelling";

  return (
    <form
      onSubmit={(event) => {
        event.preventDefault();
        if (!prompt.trim() || busy) return;
        startStream(prompt.trim());
      }}
    >
      <div role="status" aria-live="polite">
        {announcement}
      </div>
      <label htmlFor={composerId}>Message</label>
      <textarea
        id={composerId}
        ref={composerRef}
        value={prompt}
        disabled={busy}
        onChange={(event) => setPrompt(event.target.value)}
      />
      <button type="submit" disabled={busy}>
        Send
      </button>
      {status === "streaming" && (
        <button type="button" onClick={cancel}>
          Stop generating
        </button>
      )}
      <article aria-label="Assistant message">{text}</article>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Notice what this machine does that the boolean version never did. cancelling is a real status, abort is wired into fetch, late chunks die against a turn id, and focus returns in finally. Stop leaves the tab order when the turn is no longer streaming. Does Stop remain focused after it disappears from the tree? Not here, because we move focus before unmounting that button.

Live region rules that keep cancel hearable

A live region can fail in two opposite ways, and both showed up while I was tracing this demo. It can stay silent after Escape, which is the opening bug. It can also announce every token, which makes the cancel sentence impossible to hear. Neither failure is fixed by adding more ARIA to the spinner.

Use these rules as a regression list:

  • Announce start once: “Generating response”.
  • Do not pipe streaming tokens into aria-live.
  • Announce one terminal event: complete, cancelled, or failed.
  • Keep role="status" and aria-live="polite" unless recovery is time-critical.
  • Never steal focus into the live region; focus belongs on Stop, then the composer.

If you need a mental model, treat the live region like a station announcement rather than a teleprompter. People need the train’s state, not every syllable of the timetable as it prints. Streaming tokens belong in the document. Status belongs in one short sentence.

Why a mocked timer will not catch this

Local mocks resolve on a timer and almost never race your abort handler. A remote stream will race it, especially when the first byte is slow and the last byte arrives after abort(). That is why this debugging path needs a chunked HTTP response instead of setTimeout pretending to be a model.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode’s free model access and free server option are useful here only as a remote stream you can abort without inventing a fake read(). I am not quoting model names, quotas, hardware, or duration, because those are easy to get wrong and I did not measure them for this writeup. If you already have an endpoint, keep it; the state machine does not care who owns the GPU.

QA matrix for the exact failed transition

Do not trust a screenshot of a frozen bubble. Run the transition that failed: streaming, then Escape, then a focused composer with one quiet terminal announcement. Write down browser, OS, and assistive-technology versions next to the result, because “it worked on my laptop” is not a matrix.

Environment Assistive tech Transition Pass if
Chromium current, macOS VoiceOver Escape during stream One “Generation cancelled”; textarea focused
Safari current, macOS VoiceOver Stop, then Tab Stop gone; composer back in tab order
Firefox current, Windows NVDA Abort mid-chunk No leftover “generating”; partial text kept
Chromium, Windows Keyboard only Escape, type, Enter New prompt submits; old turn stays cancelled
Any, reduced motion none Cancel Focus does not jump into the transcript

Also test the ugly cases, because they are the ones that resurrect streaming:

  • Abort before the first byte arrives.
  • Abort after the last byte but before you mark complete.
  • A chunk that arrives after abort().
  • Double Escape, and Send while cancelling.

If any of those flips the turn back to streaming, your turn id guard is missing and the composer will disable itself again. That regression is the original bug coming home.

Limitations, and who should not copy this

This pattern is for a single in-flight browser turn with a cancel control the user can reach without a pointer. It is not a server orchestration framework, and it will not stop backend work unless your server honors abort or an explicit cancel POST. I did not benchmark throughput, and I am not claiming a conformance badge for any browser pairing above.

Skip this approach if you are building:

  • Non-streaming forms that already submit, disable, and re-enable in one round trip.
  • Native mobile chat, where focus restoration follows a different toolkit.
  • Multi-agent canvases with several concurrent streams (you need a per-turn abort map).
  • Voice-only UIs that need an interrupt model beyond Escape and a Stop button.

Late server tokens can still do work after the client hangs up, which is a backend problem this composer restore cannot solve. Pair the UI abort with a cancel request if leftover work matters in your system. Users still need the textarea back either way, because they came here to type the next prompt, not to admire a frozen Stop control.

What I would debug next time, in order

When a cancel looks fake, I now walk this sequence instead of restyling the spinner or adding one more live region. The order is the reusable technique. The React snippet is only one encoding of that order.

  1. Is AbortSignal actually passed into fetch, or into whatever wraps your EventSource?
  2. Does status include cancelling, or only a sticky busy: true?
  3. Does a turn id drop late chunks after the user has cancelled?
  4. Does focus return to the composer in a microtask after the terminal state?
  5. Does the live region announce one terminal sentence, and only one?

Ask those five questions before you add another ARIA attribute to the bubble. ARIA cannot abort a socket, and it cannot enable a textarea that React still thinks is streaming. The Stop button was never the liar. The state machine was, and Escape was the witness.

Reproduce the Escape transition in your own browser, with screen-reader versions written beside the pass or fail. The bug is not the missing spinner. The bug is a composer that never becomes a composer again.

Top comments (0)