DEV Community

babycat
babycat

Posted on

A Superseded AI Stream Needs a Stable Status Region, Not a Focus-Stealing Toast

I sat down to cancel a streamed rewrite and lost the caret to a toast that vanished before I could read it. I pressed Escape because the tone had drifted, and I expected the textarea to keep focus the whole time. Instead a small notice mounted, called focus, and removed itself on a timer. The screen reader then announced Draft saved, which belonged to an older success, not to the cancel I had just requested.

Have you ever trusted a status line that was describing a state you had already left behind? I rebuilt that exact failure as a single-file lab, after a week of DEV posts about vibe-coded pages and whether they stay operable. That conversation was only a topic nudge, not evidence that the toast behavior itself was correct. This walkthrough is a reconstructed reproduction, not a customer incident, and it does not report production metrics.

The remote leg can use MonkeyCode's free model access and its free server option, only so you can abort a real hosted stream. Disclosure: This article was prepared as part of MonkeyCode's product outreach. I am not claiming a particular model, quota, region, or uptime, because those details were not part of what I verified. The defect sits in status and focus code, not in whatever endpoint happens to answer the request.

What broke, in the order I saw it

The visible symptom is small enough that a sighted click-through can miss it entirely. After Escape, the textarea loses focus, and Tab from the document body skips the composer until a click. A second later the page looks calm, because the toast has already unmounted and left no trace. Why would a cancel path announce a save if the network panel showed an aborted request?

I write the sequence down before I change a single line, because memory is a terrible debugging log. The order is submit, tokens, Escape, toast focus, toast removal, then a late status write from the previous generation. If you skip that timeline, you will fix the toast and leave the lying announcement in place. That is the retrospective method I trust: freeze the timeline, then change one writer at a time.

A state table before any more code

Phase Trigger Focus What the status region may say Stream
idle load composer nothing none
connecting submit composer Connecting to the suggestion stream. abort older, open new
streaming first chunk composer Suggestion stream started. read body
superseded submit while active composer Previous suggestion stopped. A newer request is starting. abort old generation
cancelling Escape or Cancel composer Cancelling the suggestion. abort current
cancelled abort settles composer Suggestion cancelled. Composer is ready. closed
failed network or bad status composer, then Retry Suggestion failed. You can retry from the composer. closed

That table is the artifact I now keep open beside the component while I edit any transition. If a transition is not listed in the table, the code is not allowed to announce it. Does your streaming panel have a row for a newer request replacing this one, or only a happy path and a generic error? I draw the table before I touch fetch, because the announcement text is part of the interface contract.

How I trace it without guessing

I treat this like any other race condition, not like an accessibility mystery that needs a special theory. These checks are cheap to add, and they transfer cleanly to other live regions you already own.

  • I log every phase change with a generation integer, the previous phase, and a short reason string.
  • I listen for focusin and print the target id, so a toast cannot steal focus silently.
  • I observe the status node and record every text change, including clears, together with a timestamp.
  • I abort the in-flight work with AbortController, then ignore any response whose generation id no longer matched.
  • I refuse to map an older HTTP 200 onto the current phase, even when its body says the draft was stored.

A listener that convicts the toast

A one-line focus logger is enough to convict a toast of stealing the caret after Escape. Paste it before you restyle anything, and keep the phase variable in the same closure.

document.addEventListener("focusin", (event) => {
  const id = event.target.id || event.target.tagName;
  console.log(JSON.stringify({ t: performance.now(), id, phase }));
});
Enter fullscreen mode Exit fullscreen mode

When you run that listener, a toast that calls focus prints its id after Escape, before the status text settles. I am describing the check to run, not a captured production trace or a measured latency budget. Would you have noticed that focus jump if you had only been watching the network panel carefully?

The root cause was three writers

Why the live region lied

The toast called focus on mount because a library example treated every notice as if it were a dialog. It was not a dialog, it had no accessible role, and it disappeared on a short timer. When it unmounted, focus fell back to the document body instead of returning to the textarea. That is a real focus bug, but it was not the whole lie the screen reader spoke.

The status region still held the words Draft saved from the previous successful run I had finished. My render path appended text and never replaced it, so an empty update did not clear the node. A late response from generation three then wrote a save message while generation four was already cancelling. The browser coalesced those polite updates, and the screen reader spoke the stale save out loud.

I had also classified a disconnect from the free server as success, because an earlier handler treated any completed fetch as saved. A completed fetch can mean saved, cancelled, or superseded, depending on which generation still owns the request. Which of those three outcomes did my old mapper actually know when that late response body arrived? None of them, until I passed the reason into the announcer instead of inferring it from status codes.

One announcer, and focus stays put

This reproduction is a proposal you can paste into a static file and drive by hand. It does not vendor a model SDK, and it does not claim the timings you will see on your own network. Point /api/suggest at a proxy you control. Do not invent a vendor path you have not read in current docs.

<form id="composer">
  <label for="prompt">Rewrite brief</label>
  <textarea id="prompt" name="prompt"></textarea>
  <button type="submit">Stream suggestion</button>
  <button type="button" id="cancel">Cancel</button>
  <button type="button" id="retry" hidden>Retry</button>
</form>
<p id="stream" tabindex="-1"></p>
<div id="status" role="status" aria-live="polite" aria-atomic="true"></div>
Enter fullscreen mode Exit fullscreen mode
const announcements = {
  connecting: "Connecting to the suggestion stream.",
  streaming: "Suggestion stream started.",
  superseded: "Previous suggestion stopped. A newer request is starting.",
  cancelling: "Cancelling the suggestion.",
  cancelled: "Suggestion cancelled. Composer is ready.",
  failed: "Suggestion failed. You can retry from the composer."
};

let phase = "idle";
let generation = 0;
let controller = null;
const status = document.querySelector("#status");
const prompt = document.querySelector("#prompt");
const retry = document.querySelector("#retry");
const stream = document.querySelector("#stream");

function announce(next, reason) {
  if (phase === next) return;
  phase = next;
  status.textContent = announcements[next] || "";
  retry.hidden = next !== "failed";
  if (document.activeElement !== prompt) prompt.focus();
  console.log(JSON.stringify({ next, reason, generation }));
}

async function readBody(response, mine) {
  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  while (true) {
    const step = await reader.read();
    if (mine !== generation) return;
    if (step.done) return;
    stream.textContent += decoder.decode(step.value, { stream: true });
  }
}

async function startStream(text) {
  const mine = ++generation;
  if (phase === "connecting" || phase === "streaming") {
    announce("superseded", "new-submit");
  }
  controller?.abort();
  controller = new AbortController();
  announce("connecting", "submit");
  stream.textContent = "";
  try {
    const response = await fetch("/api/suggest", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ text, generation: mine }),
      signal: controller.signal
    });
    if (mine !== generation) return;
    if (!response.ok || !response.body) throw new Error("bad-status");
    announce("streaming", "headers");
    await readBody(response, mine);
  } catch (error) {
    if (mine !== generation) return;
    if (error.name === "AbortError") announce("cancelled", "abort");
    else announce("failed", "request");
  }
}

document.querySelector("#composer").addEventListener("submit", (event) => {
  event.preventDefault();
  startStream(prompt.value);
});

document.querySelector("#cancel").addEventListener("click", () => {
  announce("cancelling", "button");
  controller?.abort();
});

prompt.addEventListener("keydown", (event) => {
  if (event.key === "Escape") {
    event.preventDefault();
    announce("cancelling", "escape");
    controller?.abort();
  }
});
Enter fullscreen mode Exit fullscreen mode

Save the script as announcer.mjs only if you extract the logic without the DOM, then run node --check announcer.mjs before you open the page. The important line is the generation guard that runs before any announcement is written into the region. A late body chunk must not be allowed to speak for a request the user already replaced. I move focus back to the composer on purpose, because this notice is not a control the user asked to operate. If you truly need a blocking choice, use a real dialog with a focus trap and a named cancel, not a timer.

Checks I run before I trust the region

I do not claim any conformance level from this checklist alone, and you should not infer one. I only claim that these transitions are the ones that lied, and I do not want them failing quietly again.

  1. Start a stream, press Escape, and confirm that keyboard focus remains on the textarea the whole time.
  2. Submit again while tokens are still arriving, and confirm the status says the previous suggestion stopped.
  3. Force a failed response, and confirm Retry is visible, focusable, and does not replace the prompt text.
  4. Leave an old Draft saved string in the status node, then cancel, and confirm the node text was replaced rather than appended.
  5. Tab through the form with a screen reader and confirm the stream output is readable without stealing the caret.
  6. Repeat the cancel with a pointer, the keyboard, and a screen reader, in at least two desktop browsers.

Environment notes

If you reproduce it, tell me the browser, the OS, the assistive technology version, and the exact transition that lied. That kind of failure report is more useful to me than another screenshot of the happy path. A polite region is enough for cancel and supersede, because focus never leaves the composer and the user can keep typing. An assertive region is the wrong volume here, unless you are warning about lost work that a polite update might miss.

Who should skip this pattern

This pattern is only a client state machine for one composer and one polite status region beside it. It will not save you if the product needs an interruptive alert or a guaranteed capacity contract. It also will not replace a proper design study of whether people want streamed suggestions at all. A free model route and a free server option are convenient for the remote leg of a lab reproduction. They are the wrong dependency if you need a named quota, a pinned model, or an uptime promise I have not verified.

Do not point production traffic at an unlabeled free option and then encode its hiccups as a successful save. Do not use a polite live region for a destructive confirmation that the user must not miss. Do not autofocus a toast that is not a dialog and does not expose a reliable keyboard dismiss. If your only goal is visual polish, this debugging loop will feel slow, and you should not pretend the status region is optional.

I would reuse the same generation log the next time a stream and a notice disagree about the outcome. The question I keep taped above the monitor is almost embarrassingly simple, and it still catches me. If this response belongs to an older request, why is the client still allowed to speak it?

If you want a hosted stream you can cancel on purpose, MonkeyCode's free model access and free server option can stand up that remote leg. Keep the state table next to the component, and treat any availability detail you have not checked as unknown. I would rather you reproduce the cancel path than take my word for any of the announcement text.

Top comments (0)