Skip to content

fix(cli): withLoading no-ops when stream is not a TTY#14

Merged
ivanmaierg merged 1 commit into
mainfrom
fix/with-loading-respects-non-tty
May 13, 2026
Merged

fix(cli): withLoading no-ops when stream is not a TTY#14
ivanmaierg merged 1 commit into
mainfrom
fix/with-loading-respects-non-tty

Conversation

@ivanmaierg

Copy link
Copy Markdown
Owner

Summary

withLoading now short-circuits unconditionally when stream.isTTY !== true, restoring the no-op contract documented in its own header comment. No writes, no spinner setup, just await fn().

Root cause

withLoading gated the no-op path on isSpinnerEnabled(stream), which returns true when FORCE_COLOR=1 is set in the env (intentional behavior — that's what test S5 asserts). In environments where FORCE_COLOR is exported (e.g. the local dev shell here has FORCE_COLOR=3), isSpinnerEnabled(stream) returns true even with isTTY=false, so withLoading painted a spinner frame and a cleanup erase into the fake stream — two writes where the contract requires zero.

The doc comment on withLoading already stated the stronger contract: "TTY-false path: zero writes, zero overhead". The implementation just didn't honor it.

Fix

Add an explicit if (stream.isTTY !== true) return fn(); guard before the isSpinnerEnabled check inside withLoading. isSpinnerEnabled is left untouched — its FORCE_COLOR semantics are still useful for callers that want to ask the question — but withLoading's stronger "never animate into a pipe" rule wins on its own path.

Why the stronger rule is correct: animated cursor-return frames written to a non-TTY corrupt downstream consumers (captured stdout, CI log buffers, file redirects). FORCE_COLOR is meant to opt INTO color in pipes where the producer knows the consumer can render ANSI — it should not opt into an animated spinner, which assumes a live terminal that handles \r.

Testing checklist

  • bun test src/cli/loading.test.ts — 15/15 pass (was 13/15)
  • bun test full suite — 237/237 pass, no regressions
  • T-TTY-1 / T-TTY-2 still pass (TTY-true spinner rendering unchanged)
  • T-REJECT-1 still passes (cleanup still fires on rejection)
  • All 8 isSpinnerEnabled truth-table cases still pass

isTTY=false is now an unconditional short-circuit, regardless of
FORCE_COLOR. Painting spinner frames into a pipe corrupts downstream
consumers (captured stdout, CI buffers, log files), so the spinner
must never animate when the stream is not a real terminal.

isSpinnerEnabled still honors FORCE_COLOR for callers that want to ask
the question, but withLoading's stronger contract — documented in its
own header comment — wins on the no-op path.
@ivanmaierg ivanmaierg merged commit 66256bc into main May 13, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant