DEV Community

babycat
babycat

Posted on

Streaming Chat Needs In-Place Completion, Not a Remount That Drops Focus

I was one Tab away from Stop generating when the last token landed in the bubble. The Stop control vanished, and my keyboard focus jumped into the browser chrome without any spoken warning. VoiceOver stayed completely quiet, which felt worse than an error because nothing explained that disappearance. Have you ever watched a successful stream steal the only keyboard focus you still had?

The race is a completion bug

This was not a missing Stop handler, and it was not a flaky screen reader gesture either. The mouse path looked polished because the pointer never depended on that doomed button remaining mounted. I kept reproducing the break with the same four steps, and they failed whenever the last chunk arrived late.

The four steps that exposed it

  1. Focus the composer first, then submit a short prompt with Enter so streaming actually starts.
  2. Tab once so Stop generating receives focus while tokens are still visibly arriving in the bubble.
  3. Wait for completion without pressing Stop, Copy, Retry, or any other control in the transcript.
  4. Confirm the next Tab lands in browser chrome because the page no longer holds a focused node.

Why does a finished reply feel like an accessibility crash instead of a calm success state? The streaming element is thrown away, and a static twin mounts with a brand new identity. The focused Stop button dies during that commit, so the document activeElement becomes the body or null.

Draw the state table before you patch

I should have drawn this table before touching styles, because the missing row was named completing. Most chat demos jump from streaming into a brand new idle bubble and then call that success. Keyboard users live in the completing row, which is exactly where identity changes do the damage.

State Visible UI Focus owner Live announcement Keyboard action
idle Composer enabled, no active reply Composer None Submit the prompt
streaming Same reply node plus Stop Stop or composer Reply is streaming Cancel the request
completing Same reply node, busy ends Stay put, then move on purpose Reply is complete Copy or retry
failed Same reply node plus Retry Retry Reply failed Retry or dismiss
cancelled Partial reply, composer enabled Composer Reply cancelled Edit and resubmit

If completing remounts the article, every focused child inside that article is gone after paint. Copy cannot restore focus when it lives on a different node than the Stop control you were holding. Does your state machine even have completing, or do you collapse it into idle after the final token?

A single-file reproduction

The demo below is a local reproduction, not a production chat client with analytics or authentication. Paste it into index.html, serve the file, and use the delayed complete toggle to force the race. A polite local timer often hides the bug because you tab away before completion can fire. I needed irregular gaps between chunks so Stop could still be focused when the complete event arrived.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <title>In-place stream completion demo</title>
  <style>
    :root { font-family: system-ui, sans-serif; line-height: 1.45; }
    #reply[aria-busy="true"] { outline: 2px dashed #555; }
    button:focus, textarea:focus { outline: 3px solid #0b57d0; }
    .row { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.5rem; }
  </style>
</head>
<body>
  <h1>Streaming completion focus race</h1>
  <p>Submit, Tab to Stop, wait for finish, then Tab again.</p>
  <div id="status" class="visually-hidden" aria-live="polite"></div>
  <article id="reply" aria-busy="false" hidden>
    <h2>Assistant reply</h2>
    <div id="reply-text"></div>
    <div class="row">
      <button id="stop-or-copy" type="button">Stop generating</button>
    </div>
  </article>
  <form id="composer-form">
    <label for="composer">Message</label>
    <textarea id="composer" rows="3">Explain focus loss when a stream remounts.</textarea>
    <div class="row">
      <button type="submit">Send</button>
      <label><input id="delay-complete" type="checkbox" checked /> Delay the complete event</label>
      <label><input id="remount" type="checkbox" /> Broken remount path</label>
    </div>
  </form>
  <script>
    /** @typedef {'idle' | 'streaming' | 'completing' | 'failed' | 'cancelled'} ChatStatus */
    const reply = document.getElementById('reply');
    const replyText = document.getElementById('reply-text');
    const actionBtn = document.getElementById('stop-or-copy');
    const composer = document.getElementById('composer');
    const live = document.getElementById('status');
    const remountToggle = document.getElementById('remount');
    const delayToggle = document.getElementById('delay-complete');

    /** @type {{ status: ChatStatus, text: string, abort: AbortController | null }} */
    const machine = { status: 'idle', text: '', abort: null };

    function announce(message) {
      live.textContent = '';
      requestAnimationFrame(() => { live.textContent = message; });
    }

    function renderInPlace() {
      reply.hidden = machine.status === 'idle' && !machine.text;
      reply.setAttribute('aria-busy', String(machine.status === 'streaming'));
      replyText.textContent = machine.text;
      if (machine.status === 'streaming') {
        actionBtn.textContent = 'Stop generating';
        actionBtn.dataset.mode = 'stop';
      } else {
        actionBtn.textContent = 'Copy reply';
        actionBtn.dataset.mode = 'copy';
      }
    }

    // Broken path: destroy the article and mount a twin after the last token.
    function renderWithRemount() {
      const next = reply.cloneNode(true);
      reply.replaceWith(next);
      next.id = 'reply';
      next.querySelector('#reply-text').textContent = machine.text;
      next.setAttribute('aria-busy', 'false');
      const btn = next.querySelector('#stop-or-copy');
      btn.textContent = 'Copy reply';
      btn.dataset.mode = 'copy';
    }

    function setStatus(next, announcement) {
      const before = document.activeElement;
      console.log('before complete', before && before.id);
      machine.status = next;
      if (next === 'completing' && remountToggle.checked) {
        renderWithRemount();
      } else {
        renderInPlace();
      }
      if (next === 'completing' && before && before.id === 'stop-or-copy') {
        document.getElementById('stop-or-copy').focus();
      }
      console.log('after complete', document.activeElement && document.activeElement.id);
      announce(announcement);
    }

    async function fakeStream(signal) {
      const chunks = ['Streaming ', 'chat needs ', 'one stable ', 'article node.'];
      for (const chunk of chunks) {
        if (signal.aborted) throw new DOMException('Aborted', 'AbortError');
        await new Promise((r) => setTimeout(r, 400));
        machine.text += chunk;
        renderInPlace();
      }
      if (delayToggle.checked) {
        await new Promise((r) => setTimeout(r, 1600));
      }
    }

    document.getElementById('composer-form').addEventListener('submit', async (event) => {
      event.preventDefault();
      machine.abort = new AbortController();
      machine.text = '';
      machine.status = 'streaming';
      renderInPlace();
      reply.hidden = false;
      announce('Reply is streaming');
      actionBtn.focus();
      try {
        await fakeStream(machine.abort.signal);
        setStatus('completing', 'Reply is complete');
        machine.status = 'idle';
      } catch (err) {
        if (err.name === 'AbortError') {
          setStatus('cancelled', 'Reply cancelled');
          composer.focus();
        } else {
          setStatus('failed', 'Reply failed');
          actionBtn.textContent = 'Retry';
          actionBtn.dataset.mode = 'retry';
          actionBtn.focus();
        }
      }
    });

    document.body.addEventListener('click', (event) => {
      const btn = event.target.closest('#stop-or-copy');
      if (!btn) return;
      if (btn.dataset.mode === 'stop' && machine.abort) machine.abort.abort();
      if (btn.dataset.mode === 'copy') navigator.clipboard.writeText(machine.text);
    });
  </script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Serve it with a static file command, then run the four steps with the remount checkbox on and off.

python3 -m http.server 4173
Enter fullscreen mode Exit fullscreen mode

What a free remote stream actually exposed

Local setTimeout chunks behave like a metronome, which makes the remount race annoyingly easy to miss. Disclosure: This article was prepared as part of MonkeyCode's product outreach, which I am declaring before describing that remote stream. MonkeyCode is an open-source project with free model access and a free server option. I pointed this same demo at that remote stream only as bursty traffic, not as a product benchmark. I am not publishing model names, token quotas, hardware details, or latency numbers I did not measure.

You can keep the fake timer for unit tests and reserve the remote stream for this focus race. The debugging value is irregular completion, not a model leaderboard or a hosting comparison. Wire any SSE or fetch stream into the same machine, and keep the article node stable when done arrives.

// Labeled example: point this at your own stream URL, including a free remote server.
async function readRemoteStream(url, signal, onChunk) {
  const response = await fetch(url, { signal, headers: { Accept: 'text/event-stream' } });
  if (!response.ok || !response.body) throw new Error('stream failed');
  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    onChunk(decoder.decode(value, { stream: true }));
  }
}
Enter fullscreen mode Exit fullscreen mode

Root cause: identity change on complete

The React tree that bit me looked harmless until I printed keys during the streaming to complete transition. One branch mounted a streaming message, and the other branch mounted a finished message with a different key. Two components and two keys mean one focused button that cannot survive the next commit phase. React is not being rude here; you asked for a new tree and it faithfully threw the old one away.

// Broken: completion remounts the focused Stop button out from under the keyboard.
{status === 'streaming' ? (
  <StreamingMessage key="hot" text={draft} onStop={stop} />
) : (
  <FinishedMessage key="cold" text={draft} />
)}
Enter fullscreen mode Exit fullscreen mode

Imagine handing someone a postcard, then snatching it and sliding a photocopy under their finger instead. Their fingertip was on the stamp, and now the stamp exists on a different piece of paper. Screen readers experience the same theft when the article node is replaced at the exact completion moment. Is a prettier finished bubble worth dropping the only keyboard user who was supervising the stream?

Debug the trapdoor before you reach for ARIA

When focus disappears, I do not start with ARIA attributes or a fresh color pass on the Stop button. I log document.activeElement before and after the status write that finally marks the stream complete. If the element is null or BODY after commit, a node was removed while it still held focus. That console pair is faster than a full axe scan for this exact class of keyboard bug.

before complete  button#stop-or-copy
after complete   body          <-- remount stole focus
after complete   button#stop-or-copy  <-- in-place mutation kept it
Enter fullscreen mode Exit fullscreen mode

The fix: mutate, then restore on purpose

Keep one article node for the active reply, and mutate its text instead of swapping the component type. Drive a typed status field, then change aria-busy, button labels, and announcements from that single source. If Stop is focused when status becomes complete, move focus to Copy or back to the composer. Pick one restoration target and test it; do not hope the browser will invent a reasonable fallback.

type ChatStatus = 'idle' | 'streaming' | 'completing' | 'failed' | 'cancelled';

function completeInPlace(opts: {
  article: HTMLElement;
  actionBtn: HTMLButtonElement;
  composer: HTMLTextAreaElement;
  restore: 'copy' | 'composer';
}) {
  const hadStop =
    document.activeElement === opts.actionBtn &&
    opts.actionBtn.dataset.mode === 'stop';

  opts.article.setAttribute('aria-busy', 'false');
  opts.actionBtn.dataset.mode = 'copy';
  opts.actionBtn.textContent = 'Copy reply';

  if (!hadStop) return;
  if (opts.restore === 'copy') opts.actionBtn.focus();
  else opts.composer.focus();
}
Enter fullscreen mode Exit fullscreen mode

Notice the live region only announces state changes, not every token, because token chatter creates a backlog. Tokens belong in the article as text; the live region is for complete, failed, and cancelled transitions. Pointer users can still click Stop, and keyboard users can still press it without a separate hidden control.

Expected UI states after the patch

  • Streaming: the same article stays in the tree, aria-busy="true", Stop is a real button.
  • Completing: text is unchanged in place, aria-busy="false", Stop becomes Copy without a remount.
  • Cancelled: partial text remains, composer is enabled, focus returns to the composer on purpose.
  • Failed: Retry replaces Stop on the same node, and Retry receives focus if the stream died under the keyboard.

Keyboard and screen-reader checks

I did not collect a formal assistive technology lab report for this article, so treat the matrix as a reproduction invite. Please record browser, OS, and assistive-technology versions plus the exact transition that failed on your machine. The transition that matters is streaming with Stop focused, then complete, then the very next Tab key.

Environment Action Expected Failure to log
Chrome + keyboard only Stop focused, stream completes Copy keeps focus Focus jumps to the URL bar
Firefox + keyboard only Cancel during stream Composer focused, partial text remains Stop leftover, composer disabled silently
Safari + VoiceOver Complete announcement One "Reply is complete" Tokens spoken, then silence on finish
NVDA + Chrome Tab after complete Next control inside the transcript Tab lands in browser chrome

If the remount checkbox still passes with a mouse, do not trust that pass. The bug is pointer-invisible until a keyboard user is holding Stop at the wrong millisecond.

Limitations, and who should not copy this

This pattern assumes you can keep a stable DOM identity for the message that is currently streaming. Virtualized transcripts that recycle row nodes will remount anyway, and you must restore focus after recycle. A free remote server is useful for irregular chunks, but it is not a production inference SLA.

Who should skip this approach? Teams that cannot own abort, focus, and status in one state machine. Also skip a free shared server if you must keep prompts off any third-party infrastructure for compliance reasons. If you only ship pointer kiosks, the remount still wastes work, but the focus trapdoor shows up less often.

Do not treat aria-live as the whole repair, and do not claim WCAG conformance from this demo alone. In-place mutation fixes the identity crash; it does not replace contrast, names, or a real abort owner. Successful streams should feel like the same card receiving its last word, not like a trapdoor under focus.

If you need bursty remote chunks to reproduce the race, MonkeyCode's free server option is one available source. Then run the four steps again, and do not ship until Stop can die without taking the keyboard with it.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

Top comments (0)