DEV Community

babycat
babycat

Posted on

Hidden-Tab Streams Need a Visibility Abort, Not a Frozen Generating Label

I hid the chat tab to answer Slack, came back thirty seconds later, and the interface still said Generating. The Stop button looked pressed, the composer stayed disabled, and my screen reader repeated a status that was already dead. Had the remote stream finished, stalled, or silently aborted while the page sat frozen in the background? That stuck Stop control is the failure I want to walk through, from the first symptom to a typed recovery.

The frozen Stop I came back to

Browser agents are having a loud week, and too many demos assume the tab stays focused like a stage light. Mine did not stay focused, and the remote completion kept the React tree in streaming after the socket was already gone. I had been reading chunks from a remote endpoint, switched away for a short reply, and the Page Lifecycle freeze cut the network without notifying my state machine. When I returned, keyboard users could not reach the composer, because a dead Stop button still owned the only enabled tab stop.

The pointer story was ugly, but the keyboard story was worse, because Tab died on a disabled textarea. The remaining control called abort() on an AbortController that had already finished its useful life. VoiceOver kept saying “Generating,” which is a lie, not an announcement strategy. Why do we treat document.visibilityState as a performance footnote when it is a stream lifecycle event?

Here is the interaction I keep reproducing in a single-file demo.

  1. Start a remote stream with focus in the composer, then Tab once so Stop is a real tab stop.
  2. Hide the tab for about thirty seconds, or longer if the browser discards a frozen background page.
  3. Return with the keyboard only, and notice that Stop still claims the stream is live.

Expected states should have been on the whiteboard before anyone reached for aria-live.

State table before any ARIA

State Composer Primary control Live status Focus origin
idle enabled none none composer
connecting disabled Cancel Connecting to the model. Cancel
streaming disabled Stop Generating. Stop
hidden_aborted disabled Retry banner Generation stopped because the tab was hidden. Retry
failed disabled Retry banner Specific network or HTTP error. Retry
complete enabled none Response complete. composer
cancelled enabled none Generation cancelled. composer

Notice that hidden_aborted is not failed and is not streaming either. Mixing those three phases is how you ship a Stop button that never unlocks for keyboard users. Also notice the focus origin is a real tab stop, not a polite live region pretending to be a button. Live regions announce a change; they do not receive Tab, Enter, or a screen-reader swipe.

Reproducing the death without a mock stream

A setTimeout token pump will not die when you hide the tab, which is why this bug survives unit tests. I needed a real remote completion so the browser could freeze the page, drop the connection, and leave my state machine behind. Disclosure: This article was prepared as part of MonkeyCode's product outreach. I pointed the demo at MonkeyCode's free model access and free server option, because that gave me a remote stream I could cancel, stall, and lose without standing up my own inference box.

I am not going to quote quotas, model names, or uptime claims I cannot verify from a primary source in this draft. The only fact this reproduction needs is a free remote endpoint, so fetch plus AbortController fail the way production fails. If you already have another completion URL, paste it into the demo config instead. The visibility owner does not care who owns the socket, only that the socket can actually die.

The reproduction stays small on purpose: typed states, a composer, a transcript region, and a banner that becomes the next tab stop. There is no design-system chrome and no remount of the chat tree when status changes. Remounting is how you drop focus, and focus loss is the second bug hiding under the frozen label.

Root cause: generating outlived the network

I logged three timestamps while the tab was hidden: visibilitychange, the fetch abort or error, and the React phase that still said streaming. The first two timestamps moved. The third never moved, which is the whole incident in one sentence. My effect had subscribed to chunks and to user-initiated abort, but not to the page becoming hidden, and Chrome can freeze a background tab hard enough that your progress handler simply never runs again.

There is a second trap sitting next to that missing listener. Some browsers fire visibilitychange but keep the socket alive until a later freeze or a full discard. If you abort on the first hidden event, you might cancel a stream the user still wants after a one-second glance away. If you never abort, you lie for the entire Slack detour. The durable approach is to treat hidden as “make the UI honest immediately,” then abort only if the page is still hidden after a short, documented grace period.

I also found a privacy bug sitting under the lifecycle bug, and it was easier to miss. After I finally flipped the phase to an error, an effect retried the same prompt automatically as if recovery meant resend. That resend is a consent failure, not a convenience feature. The user hid the tab. They did not ask the product to ship the prompt a second time.

Debugging sequence I will keep reusing on the next stream owner:

  1. Log document.visibilityState, document.wasDiscarded when it exists, and your stream enum on every transition.
  2. Confirm Stop and the visibility owner cancel the same AbortController instance for this turn.
  3. Prove no useEffect auto-retries on hidden_aborted; retry must be an explicit user action.
  4. Check that the banner button sits in tab order before the disabled composer, not after it.

A typed visibility owner

Here is the state machine from the reproduction. Treat it as demo code, not production telemetry from a shipped app.

type StreamPhase =
  | "idle"
  | "connecting"
  | "streaming"
  | "hidden_aborted"
  | "failed"
  | "complete"
  | "cancelled";

type StreamState = {
  phase: StreamPhase;
  prompt: string;
  text: string;
  error: string | null;
  hiddenAt: number | null;
};
Enter fullscreen mode Exit fullscreen mode

The owner is a small class so React is not the source of truth for abort. React renders the phase. The owner owns the socket.

const HIDDEN_GRACE_MS = 4000;

class VisibilityStreamOwner {
  private controller: AbortController | null = null;
  private graceTimer: number | null = null;

  constructor(
    private onPhase: (phase: StreamPhase, error?: string) => void,
    private onChunk: (chunk: string) => void
  ) {}

  start(url: string, prompt: string, token?: string) {
    this.cancelTimers();
    this.controller = new AbortController();
    this.onPhase("connecting");

    // Example fetch. Wire your own parser. This is not a vendor SDK.
    fetch(url, {
      method: "POST",
      signal: this.controller.signal,
      headers: {
        "Content-Type": "application/json",
        ...(token ? { Authorization: `Bearer ${token}` } : {}),
      },
      body: JSON.stringify({ prompt, stream: true }),
    })
      .then(async (response) => {
        if (!response.ok || !response.body) {
          this.onPhase("failed", `HTTP ${response.status}`);
          return;
        }
        this.onPhase("streaming");
        const reader = response.body.getReader();
        const decoder = new TextDecoder();
        while (true) {
          const { value, done } = await reader.read();
          if (done) break;
          if (this.controller?.signal.aborted) return;
          this.onChunk(decoder.decode(value, { stream: true }));
        }
        this.onPhase("complete");
      })
      .catch((error: unknown) => {
        if (this.controller?.signal.aborted) return;
        const message = error instanceof Error ? error.message : "Network failed";
        this.onPhase("failed", message);
      });
  }

  watchDocument() {
    document.addEventListener("visibilitychange", this.onVisibility);
  }

  unwatchDocument() {
    document.removeEventListener("visibilitychange", this.onVisibility);
    this.cancelTimers();
  }

  private onVisibility = () => {
    if (document.visibilityState !== "hidden") {
      this.cancelTimers();
      return;
    }
    // Honest UI first: the tab is gone, so Stop is already a lie.
    this.graceTimer = window.setTimeout(() => {
      if (document.visibilityState === "hidden") {
        this.controller?.abort();
        this.onPhase("hidden_aborted");
      }
    }, HIDDEN_GRACE_MS);
  };

  userCancel() {
    this.cancelTimers();
    this.controller?.abort();
    this.onPhase("cancelled");
  }

  private cancelTimers() {
    if (this.graceTimer !== null) window.clearTimeout(this.graceTimer);
    this.graceTimer = null;
  }
}
Enter fullscreen mode Exit fullscreen mode

The grace period is a product choice, not a law, and it belongs in the banner copy. Four seconds was long enough for a misclick onto another tab, and short enough that a frozen Generating label did not survive a Slack detour. Tune the constant, then tell the same number in the UI, because mismatched copy is how QA files “sometimes it waits, sometimes it does not.”

[visible] idle -> connecting -> streaming -> complete
                 \-> cancelled (Stop / Escape)
                 \-> failed (HTTP / network)
[hidden > 4s]    streaming -> hidden_aborted -> (explicit Retry) connecting
Enter fullscreen mode Exit fullscreen mode

Focus, announcements, and a retry that does not auto-fire

When the phase becomes hidden_aborted, render a banner that is a heading plus buttons, then move focus to Retry. Do not steal focus if the user is already elsewhere in your chrome, such as a history panel they opened before hiding the tab. The test is pointer-independent: hide the tab from the keyboard, return with Alt+Tab or Command+Tab, then Tab once into the banner.

function RecoveryBanner({
  phase,
  onRetry,
  onDismiss,
  bannerRef,
}: {
  phase: StreamPhase;
  onRetry: () => void;
  onDismiss: () => void;
  bannerRef: React.RefObject<HTMLButtonElement>;
}) {
  if (phase !== "hidden_aborted" && phase !== "failed") return null;

  const title =
    phase === "hidden_aborted"
      ? "Generation stopped because the tab was hidden"
      : "Generation failed";

  return (
    <div role="region" aria-labelledby="stream-recovery-title">
      <h2 id="stream-recovery-title">{title}</h2>
      <p>
        The remote stream ended while this page was in the background.
        Nothing was resent. Retry is a separate, explicit action.
      </p>
      <button ref={bannerRef} type="button" onClick={onRetry}>
        Retry this prompt
      </button>
      <button type="button" onClick={onDismiss}>
        Dismiss and edit
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Pair that region with a status node that is aria-live="polite" and aria-atomic="true", and only update it when the phase changes, not on every token. Token-level live regions are how you backlog a screen reader, which is a different incident than this freeze. This demo announces transitions only. The banner remains the operable control. The live region is the rumor mill, not the steering wheel.

Composer rules for the hidden-abort phase are boring on purpose, and boring is what keeps focus stable.

  • Keep the textarea in the DOM, disabled, so the layout does not jump and drop a virtual cursor.
  • Do not remount the transcript. In-place text is fine; a new tree is how focus evaporates.
  • Retry copies the last prompt into an explicit confirmation, then starts a new AbortController. Never reuse the aborted one.
  • Escape still cancels while connecting or streaming, but after hidden_aborted Escape should land on Dismiss, not on a zombie Stop.

Environment-specific QA matrix

Please run the exact hidden transition, not a mocked visibilityState in JSDOM. JSDOM will not freeze your fetch, and that is the entire point of this bug.

Environment Assistive tech Transition What must happen
Chrome current / macOS VoiceOver Hide for 4+ seconds, return Announces stopped-because-hidden; focus on Retry
Firefox current / Windows NVDA Same hide-and-return Stop is gone; composer is not auto-focused if Retry exists
Safari current / iOS VoiceOver Switch apps, return Banner is a heading in the rotor; Retry is a button
Chrome current / Android TalkBack Recents, return No auto resend; swipe hits Retry before the composer

If Retry is missing from the rotor or from tab order, you shipped a tooltip, not a recovery. If the Generating label survives the return, your owner never left streaming. If the prompt fires again without a click or Enter on Retry, you shipped a privacy incident with extra tokens on the bill.

I want the matrix filled with versions, not vibes. Send the browser, OS, and assistive-technology versions plus the exact hide-and-return transition that failed. Did the page freeze, discard, or merely blur? Those three are not the same lifecycle event, and they do not deserve the same banner copy.

What this will not save you from

This pattern will not keep generating while the tab is discarded by the browser. If you need a job to finish in the background, you need a server-side run and a notification, not a hidden fetch that you hope will survive. It will not fix model stalls that happen while the tab is visible; that is a different timer and a different control. It will not make an inaccessible composer suddenly keyboard-operable, and it will not replace consent copy either. If your product sends prompts to a remote server, say that before the first request, not after a freeze.

Skip this approach when you are building a native shell that already owns lifecycle events, or a local-only model with no network to drop. Skip it when the agent must keep running in a worker with explicit background-sync consent from the user. And please skip any conformance badge in the README, because this is a recovery mechanism, not a certificate.

I still want the interface to remain keyboard-operable through loading, error, cancellation, and this newly honest hidden death. If the Generating label outlives the socket, the Stop button is not a control anymore. It is a locked door with a green light.

Top comments (0)