I was typing a follow-up question while the previous answer was still streaming, and my caret vanished in the middle of a word.
Not a scroll jump, not a repaint — the browser had moved focus from the textarea to <body>, so my next eight keystrokes went into the void. The same build also left a screen reader user with silence, because the role="status" region that should have carried the answer was being replaced by a new node on every chunk. Two bugs, one render, and neither of them visible in the happy-path demo where you click Send once and stare politely.
So let's debug it the way I actually did: symptom, then the state table I should have written first, then the suspects I had to rule out, then a fix you can paste into one HTML file and lock down with a Playwright test. The framework does not matter here, because focus is owned by the browser and not by your component tree.
The state table I should have written first
Before touching code, write down who owns focus in every phase and how the answer is announced. If a phase transition changes the focus owner without a user gesture, that is a bug, no exceptions.
| Phase | Focus owner | Composer | Live region says | Visible controls |
|---|---|---|---|---|
| idle | whatever the user left it on | enabled, submittable | nothing | Send |
| streaming | unchanged | still focusable, submit guarded in code | "Streaming answer started." then one line at the end | Send + Stop |
| cancelled | unchanged | enabled | "Answer stopped after N characters." | Send + Retry |
| error | unchanged | enabled, error text linked | "Request failed. Retry available." | Send + Retry |
The word "unchanged" is the entire contract. My first version broke it twice per turn, once when the stream started and once when it ended.
Reproduce the failure in about twelve lines
The buggy render was short enough to be embarrassing. It rebuilt the conversation on every chunk, and it swapped the composer out for a "thinking" placeholder while streaming.
// The bug, reduced: two writes that destroy focused nodes.
function renderBuggy() {
list.innerHTML = ''; // new <li> nodes every chunk
for (const m of messages) {
const li = document.createElement('li');
li.textContent = m.text;
list.appendChild(li);
}
if (phase === 'streaming') {
form.replaceChildren(thinkingPlaceholder); // kills the focused textarea
}
}
When a focused element is removed from the document, the browser has nowhere to put focus, so it lands on <body>. When the live region is recreated per chunk, assistive technology sees a brand-new node each time, which is why announcements either repeat forever or never arrive at all.
Instrument focus exactly like you instrument network calls
The technique that found this in under a minute was a focus ledger: log document.activeElement before and after every state transition, and only complain when the owner changes without a gesture. You would never ship a fetch wrapper that silently swaps response types, right? Focus deserves the same ledger.
const owner = () => {
const el = document.activeElement;
return el ? el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') : 'none';
};
function transition(next) {
const before = owner();
phase = next;
render();
const after = owner();
if (before !== after) console.warn(`[focus] ${before} -> ${after} on ${next}`);
restoreFocus();
}
One browser tab, the console open, three keystrokes. The warning said textarea#prompt -> body on streaming, which is as close to a stack trace as focus bugs ever get.
The four suspects, and how each one fell
-
Index-keyed list items. In React this is
key={index}, in vanilla it isinnerHTML = ''. Both recreate nodes, so both can carry a focused descendant away. Confirmed guilty. -
A conditional branch that swaps the composer.
{streaming ? <Thinking/> : <Composer/>}looks harmless and unmounts your textarea. Confirmed guilty. -
disabledon the focused control. Disabling an element that currently has focus blurs it in major browsers, so "just disable the field while streaming" is its own focus bug. Rule it out by keeping the field enabled and guarding submission in code. - A recreated live region. If the status node is inside the subtree you rebuild, you get silence or spam. Keep one persistent, visually hidden node for the lifetime of the page.
Notice that only two of those are React problems. The other two are DOM problems wearing a React costume, which is why I like reproducing them without a build step first.
The fix: one persistent tree, guarded submit, sentence-level announcements
Here is the whole thing as a single file. Attributes change on every transition; nodes never do.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Focus-safe streaming chat</title>
<style>.visually-hidden{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}</style>
</head>
<body>
<main>
<h1>Ask the model</h1>
<ol id="messages" aria-label="Conversation"></ol>
<p id="status" role="status" class="visually-hidden"></p>
<form id="composer">
<label for="prompt">Your message</label>
<textarea id="prompt" rows="3"></textarea>
<button id="send" type="submit">Send</button>
<button id="stop" type="button" disabled>Stop</button>
<button id="retry" type="button" disabled>Retry</button>
<p id="error" role="alert" hidden></p>
</form>
</main>
<script>
const list = document.getElementById('messages');
const status = document.getElementById('status');
const form = document.getElementById('composer');
const prompt = document.getElementById('prompt');
const send = document.getElementById('send');
const stop = document.getElementById('stop');
const retry = document.getElementById('retry');
const error = document.getElementById('error');
let phase = 'idle'; // idle | streaming | cancelled | error
let controller = null;
let lastRequest = '';
let answer = null; // created once per turn, appended forever
let lastFocusedInForm = null;
function render() {
stop.disabled = phase !== 'streaming';
retry.disabled = phase !== 'cancelled' && phase !== 'error';
send.setAttribute('aria-disabled', String(phase === 'streaming'));
list.setAttribute('aria-busy', String(phase === 'streaming'));
error.hidden = phase !== 'error';
}
function announce(text) {
status.textContent = ''; // reset so repeats are announced
requestAnimationFrame(() => { status.textContent = text; });
}
form.addEventListener('focusin', e => { lastFocusedInForm = e.target; });
function restoreFocus() {
if (document.activeElement !== document.body) return; // user moved on purpose
if (!lastFocusedInForm || !document.contains(lastFocusedInForm)) return;
lastFocusedInForm.focus({ preventScroll: true });
}
function transition(next) {
phase = next;
render();
restoreFocus();
}
async function* mockStream(text, signal) {
for (const word of text.split(' ')) {
await new Promise(r => setTimeout(r, 40));
if (signal.aborted) return;
yield word + ' ';
}
}
async function ask(text) {
lastRequest = text;
const c = new AbortController();
controller = c;
answer = document.createElement('li');
answer.dataset.role = 'assistant';
list.appendChild(answer);
transition('streaming');
announce('Streaming answer started.');
let chars = 0;
try {
for await (const chunk of mockStream('Streaming answers should never steal your caret.', c.signal)) {
answer.textContent += chunk;
chars += chunk.length;
}
if (c.signal.aborted) {
transition('cancelled');
announce(`Answer stopped after ${chars} characters.`);
} else {
transition('idle');
announce(`Answer complete, ${chars} characters.`);
}
} catch (err) {
transition('error');
error.textContent = 'Request failed. Retry is available.';
announce('Request failed. Retry available.');
} finally {
controller = null;
}
}
form.addEventListener('submit', e => {
e.preventDefault();
if (phase === 'streaming') return; // guard, do not disable the field
const text = prompt.value.trim();
if (!text) return;
prompt.value = '';
ask(text);
});
stop.addEventListener('click', () => { if (controller) controller.abort(); });
retry.addEventListener('click', () => ask(lastRequest));
render();
</script>
</body>
</html>
Three details are easy to miss. First, the mock returns instead of throwing on abort, so you must check signal.aborted after the loop, while a real streaming fetch reader throws AbortError — handle both paths. Second, restoreFocus() only fires when focus fell to <body>, so it never fights a user who deliberately tabbed elsewhere. Third, announce() clears the node before setting text, because otherwise an identical message is not re-announced.
A Playwright test that fails on the old build
A repro is not a regression test until it runs in CI. This one asserts the contract from the state table rather than any wording detail.
import { test, expect } from '@playwright/test';
test('the first token does not push focus to <body>', async ({ page }) => {
await page.goto('/repro.html'); // serve it, do not use file://
await page.getByLabel('Your message').fill('hello');
await page.getByLabel('Your message').press('Tab'); // real keyboard path to Send
await page.keyboard.press('Enter');
await expect(page.locator('#status')).toHaveText(/^Answer complete, \d+ characters\.$/);
await expect(page.locator('#send')).toBeFocused();
});
Assert the shape of the announcement, not the character count, or the test will break every time you edit the mock sentence. Run it against the buggy build first and watch it fail — a regression test you have never seen fail is a rumour, not a guard.
Where the streaming endpoint came from
A 40 ms mock is fine for the focus bug, but the timing bugs that come next — a chunk boundary splitting a character, a cancel that arrives after the server already finished — need a live stream. That is the gap where MonkeyCode actually participated: it describes itself as an open-source project offering free model access and a free server option, and the operator states the free allowance is 10 million tokens. Disclosure: This article was prepared as part of MonkeyCode's product outreach.
What that gave me was a real chunked response and somewhere to host the page, so the same repro could be exercised against network timing instead of my setTimeout loop. What it did not give me is a guarantee: free allowances and free servers are availability offers, not SLAs, and they can change, queue, or disappear. Check the project's current limits and licence terms before you plan capacity, and never let a free tier be the only path in your retry logic.
Where this approach is the wrong tool
- You do not actually stream. If answers arrive whole, sentence-level announcements and abort plumbing are complexity with no payoff.
- Answers are very long. Announce once at completion, or at paragraph boundaries; per-token announcements turn a helpful answer into a hostage situation.
- Your page remounts on navigation. Restoring focus across routes needs a deliberate focus target per view, not a global safety net.
- You need a conformance claim. I am describing one control's behaviour, not auditing a product, and I am not claiming WCAG conformance for anything here.
The QA matrix I fill in for every streaming control
| Browser | Assistive tech | Expected focus owner after first token | Announcements per answer | Result |
|---|---|---|---|---|
| Chrome | NVDA | unchanged | 1 start + 1 end | |
| Firefox | NVDA | unchanged | 1 start + 1 end | |
| Safari | VoiceOver (macOS) | unchanged | 1 start + 1 end | |
| Safari (iOS) | VoiceOver | unchanged | 1 start + 1 end | |
| Chrome (Android) | TalkBack | unchanged | 1 start + 1 end | |
| any | keyboard only | unchanged | n/a |
Fill in your own versions and dates in the empty cells, because mine would be stale by the time you read this. The rows matter more than the results: a control that only passes row one is not tested.
If you reproduce this and it fails differently, tell me your browser, OS, and assistive technology versions plus the exact transition that broke — "after Stop, before Retry" is the kind of detail that turns a report into a fix. And if you want a live backend for your own repro, start from the project's own documentation rather than a blog post's summary of it, including this one.
Top comments (0)