Focus Left the Copy Button Every 200 ms: Debugging a Streaming Markdown Panel
Last Tuesday a colleague who navigates with a keyboard and reads with a screen reader sent me two sentences. "Every time I tab to Copy answer, something yanks me back to the top of the page." The answer was still streaming, so I assumed my polite live region was simply too chatty again. It was not the live region, and the real cause took me an afternoon of narrowing the surface instead of reading the code.
The symptom, written down before touching anything
Three reports arrived within a week, and all three shared the same shape, which is what made the bug tractable at all.
- Mouse users never noticed; clicking still copied the answer correctly every time.
- Keyboard users lost their place only while tokens were arriving, never after the answer finished.
- Focus did not move to a nearby element; it went to the very top of the document.
- No console error, no failed request, and no visible layout shift accompanied it.
Why does a bug that vanishes the moment the stream ends feel so slippery? Because it is almost never a network bug, even though it only shows up while data is moving. It is a DOM lifecycle bug, and lifecycle bugs reward a specific order of operations: write the symptom down, make it reproducible, and only then open the renderer.
Step 1: Turn the report into a failing test
You cannot fix a focus bug you can only sometimes see, so the first artifact was a test that fails on demand.
// tests/focus-stability.spec.ts
import { test, expect } from '@playwright/test';
test('Copy keeps focus while the answer streams', async ({ page }) => {
await page.goto('/stream-demo');
await page.getByRole('button', { name: /ask/i }).click();
const copy = page.getByRole('button', { name: /copy answer/i });
await copy.focus();
await expect(copy).toBeFocused();
const losses: string[] = [];
for (let i = 0; i < 20; i++) {
await page.waitForTimeout(200);
const active = await page.evaluate(() => {
const el = document.activeElement as HTMLElement | null;
return `${el?.tagName}#${el?.id ?? ''}`;
});
if (active !== 'BUTTON#copy-answer') losses.push(`${i * 200}ms -> ${active}`);
}
expect(losses, `focus lost at: ${losses.join(', ')}`).toHaveLength(0);
});
npx playwright test tests/focus-stability.spec.ts --repeat-each=5 failed on the very first run with output like 600ms -> BODY#. The timestamp mattered more than the failure: focus died roughly every 200 ms, which matched my chunk cadence and not the network timing at all.
Step 2: Bisect the surface, not the source
I did not read the renderer first. I disabled one subsystem at a time and reran the same test, because a bisect hands you evidence while code review only hands you opinions.
- Disabled syntax highlighting inside the answer panel.
- Disabled auto-scroll to the newest chunk.
- Moved the live region outside the answer panel.
- Throttled the chunk cadence from 200 ms to one second.
- Replaced markdown rendering with plain text output.
| Toggle | Focus survives? | What it told me |
|---|---|---|
| Highlighting off | Yes | The highlight pass rewrote the panel on every chunk |
| Auto-scroll off | No | Scrolling was innocent |
| Live region moved out | No | Announcements were a second bug, not this one |
| Slower cadence | Yes | Confirmed a per-chunk rewrite, not a race condition |
| Plain text output | Yes | The rewrite, not the parser, was the culprit |
Two rows did all the work. Highlighting, which is the expensive step, called innerHTML on the whole answer panel so that highlighted code blocks stayed current. Each call deleted every child node inside that panel, including the button I had focused a moment earlier.
Step 3: The root cause is node identity
When a focused element is removed from the DOM, focus does not travel to a sensible neighbour; in the browsers I tested, document.activeElement becomes document.body. From there the next Tab press starts at the top of the document. That is the entire mystery solved: a re-render destroyed the focused node, and the browser did exactly what it is specified to do.
The blast radius was wider than one button, which is why I did not stop at restoring focus.
// Before: every chunk rebuilds the whole panel and destroys node identity.
output.innerHTML = highlight(marked.parse(buffer));
- The focused Copy button was removed and recreated on every single chunk.
- Any text selection inside the answer was silently cleared as the reader highlighted it.
- The polite live region wrapped the whole panel, so each rewrite queued another announcement.
-
scrollTopfought with auto-scroll because the panel height kept changing under the caret.
Mouse users experienced none of this, which is exactly how a bug like this survives review for months. Isn't that always the pattern with DOM churn?
Step 4: Fix it with stable nodes and a commit boundary
The fix is not a focus-restoration shim, because restoring focus after destroying it still fires focus events that interrupt a reader. The real fix is to stop destroying nodes you did not have to touch, and to commit finished markdown at paragraph boundaries.
const transcript = document.querySelector('[data-transcript]');
// One persistent text node carries the in-progress paragraph.
const tail = document.createElement('p');
tail.className = 'stream-tail';
const tailText = document.createTextNode('');
tail.append(tailText);
transcript.append(tail);
const committed = new Set(); // block indexes we never touch again
let buffer = '';
export function onChunk(text) {
buffer += text;
const cut = buffer.lastIndexOf('\n\n');
if (cut !== -1) {
const finished = buffer.slice(0, cut);
buffer = buffer.slice(cut + 2);
commitBlocks(finished);
}
tailText.nodeValue = buffer; // node identity stays stable
}
function commitBlocks(markdown) {
for (const [index, block] of markdownBlocks(markdown)) {
if (committed.has(index)) continue;
committed.add(index);
const el = document.createElement('div');
el.dataset.blockIndex = String(index);
el.innerHTML = renderSafe(block); // sanitize once, at commit time
transcript.insertBefore(el, tail);
}
}
Because committed blocks are appended once and never rewritten, a Copy button inside an earlier paragraph is never removed, so no focus event fires and document.activeElement stays where the reader put it. The tail paragraph updates through nodeValue on a text node that outlives every chunk, which also keeps the caret and any active selection inside the answer intact.
If you inherit a renderer you cannot restructure yet, this shim buys time but is not a fix, since it still tears down and recreates the node:
function replacePreservingFocus(container, html) {
const active = document.activeElement;
const id = active && container.contains(active) ? active.id : null;
container.innerHTML = html;
if (id) container.querySelector(`#${CSS.escape(id)}`)?.focus({ preventScroll: true });
}
Step 5: Regressions that catch this whole class of bug
Asserting toBeFocused() once at the end of a stream is not enough, because the loss is transient and the final state looks perfectly fine. I count removed nodes as a proxy for focus risk, which is a cheap technique you can reuse on any panel that re-renders on a timer.
await page.evaluate(() => {
window.__removals = 0;
new MutationObserver((records) => {
for (const r of records) window.__removals += r.removedNodes.length;
}).observe(document.querySelector('[data-transcript]'), {
childList: true,
subtree: true,
});
});
After the fix, a twelve-second stream produced fewer than ten removals inside the transcript, all of them legitimate paragraph commits. Before the fix, the same stream produced hundreds, every one of them avoidable. That ratio is the kind of evidence a pull request reviewer can actually verify without watching you reproduce it live.
The QA matrix I now fill in
I tested on current stable Chrome and Safari on macOS with VoiceOver, and I could not test Windows or mobile screen readers myself, so the blanks below are honest blanks rather than copied numbers.
| Browser | OS | Assistive tech | Keyboard path tried | Focus held? |
|---|---|---|---|---|
| Chrome ___ | macOS ___ | VoiceOver ___ | Tab to Copy mid-stream | |
| Firefox ___ | Windows ___ | NVDA ___ | Caret inside answer, then Tab | |
| Safari ___ | iOS ___ | VoiceOver + hardware keyboard | Copy, then Escape | |
| Edge ___ | Windows ___ | Narrator ___ | Shift+Tab back into panel |
Where the reproduction lived
Chunk fixtures are tedious to hand-write, and a bug report is only useful when other people can hit the same transition. I used MonkeyCode's free model access to generate a set of realistic streaming fixtures and its free server option to host the demo, so a commenter can open the exact panel state that broke focus instead of describing it in prose.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
The project advertises a free token allowance alongside a free server option, and I am deliberately not quoting the number here, because quotas move and a stale figure inside a tutorial is worse than no figure at all. Check the project page for current terms and for what the open-source repository actually contains. MonkeyCode did not fix this bug; it made the reproduction cheap enough to share, which is the step that usually stalls these reports.
Limitations, and who should skip this approach
- If you render untrusted model output, sanitize at commit time. Stable nodes do not make
innerHTMLsafe, and this focus bug is trivial next to an XSS hole. - If you use a virtualized transcript, committed blocks unmount when scrolled away, so focus can still die; pin the focused block or restore by id as a fallback.
- If answers arrive in one piece, you simply do not have this problem, and the commit boundary is pure overhead.
- If the focused element has no stable unique id, the restoration shim will never find it again.
- If you are inside a native WebView with its own renderer,
document.activeElementmay not tell the whole story.
Reproduce it and tell me what breaks
Run the test above against your own streaming panel with npx playwright test --repeat-each=3, and watch document.activeElement for ten seconds while chunks arrive. If focus still escapes, I would like your browser and OS versions, your assistive technology version, and the exact transition that failed, including whether the answer had finished streaming at that moment.
If you want to try the same loop on a hosted demo, start with the project's free model access and free server option, and keep the reproduction small enough that a stranger can break it in one click.
Top comments (0)