diff --git a/packages/runtime-tests/integration/lifecycle/unmount-stream.test.tsx b/packages/runtime-tests/integration/lifecycle/unmount-stream.test.tsx
index c1d5b9f..7f83be6 100644
--- a/packages/runtime-tests/integration/lifecycle/unmount-stream.test.tsx
+++ b/packages/runtime-tests/integration/lifecycle/unmount-stream.test.tsx
@@ -88,6 +88,39 @@ test("non-interactive mode writes only last frame at unmount", async () => {
expect(postUnmountOutput).toContain("the-content");
});
+test("non-interactive unmount skips final frame when stdout is not writable", async () => {
+ const App = defineComponent(() => () => the-content);
+
+ const stdout = makeFakeWritable({ columns: 80 });
+ const stderr = makeFakeWritable({ columns: 80 });
+ const { stream: stdin } = makeFakeStdin();
+
+ (stdout as unknown as { isTTY: boolean }).isTTY = false;
+ (stdout as NodeJS.WriteStream & { writable?: boolean }).writable = false;
+
+ const chunks: string[] = [];
+ (stdout as unknown as PassThrough).on("data", (chunk: Buffer) => {
+ chunks.push(chunk.toString());
+ });
+
+ const app = createApp(App);
+ app.mount({
+ stdout,
+ stdin,
+ stderr,
+ exitOnCtrlC: false,
+ interactive: false,
+ });
+
+ await nextTick();
+ await nextTick();
+
+ app.unmount();
+ await app.waitUntilExit();
+
+ expect(chunks.join("")).not.toContain("the-content");
+});
+
test("non-interactive mode does not emit erase or cursor sequences", async () => {
// In non-interactive mode (non-TTY), the runtime should not emit any
// ANSI erase sequences or cursor manipulation — only plain text output.
diff --git a/packages/runtime-tests/integration/lifecycle/wait-flush.test.tsx b/packages/runtime-tests/integration/lifecycle/wait-flush.test.tsx
index 609ea72..87ef610 100644
--- a/packages/runtime-tests/integration/lifecycle/wait-flush.test.tsx
+++ b/packages/runtime-tests/integration/lifecycle/wait-flush.test.tsx
@@ -114,11 +114,64 @@ test("waitUntilRenderFlush resolves when stdout is not writable", async () => {
await nextTick();
(stdout as NodeJS.WriteStream & { writable?: boolean }).writable = false;
await app.waitUntilRenderFlush();
+ expect(getContentWrites(writes).some((w) => stripAnsi(w).includes("World"))).toBe(false);
app.unmount();
await app.waitUntilExit();
});
+test("waitUntilExit waits for stdout barrier when only writableLength is exposed", async () => {
+ let didBarrierCallbackFire = false;
+ let barrierWrites = 0;
+ const writes: string[] = [];
+ const stdout = {
+ columns: 80,
+ rows: 24,
+ isTTY: false,
+ destroyed: false,
+ writable: true,
+ writableEnded: false,
+ writableLength: 0,
+ write(chunk: string | Uint8Array, callback?: () => void) {
+ const text = String(chunk);
+ writes.push(text);
+ if (text === "" && callback) {
+ barrierWrites++;
+ setTimeout(() => {
+ didBarrierCallbackFire = true;
+ callback();
+ }, 20);
+ } else {
+ callback?.();
+ }
+ return true;
+ },
+ on() {
+ return this;
+ },
+ off() {
+ return this;
+ },
+ } as unknown as NodeJS.WriteStream;
+
+ const App = defineComponent(() => () => Hello);
+ const app = createApp(App);
+ const stderr = makeFakeWritable();
+ const { stream: stdin } = makeFakeStdin();
+
+ app.mount({ stdout, stdin, stderr, exitOnCtrlC: false, interactive: false, patchConsole: false });
+ await nextTick();
+ await nextTick();
+
+ const exited = app.waitUntilExit();
+ app.unmount();
+ await exited;
+
+ expect(writes.some((w) => stripAnsi(w).includes("Hello"))).toBe(true);
+ expect(barrierWrites).toBe(1);
+ expect(didBarrierCallbackFire).toBe(true);
+});
+
test("waitUntilRenderFlush waits for rerender write callback", async () => {
let didSecondWriteCallbackFire = false;
diff --git a/packages/runtime/src/render.ts b/packages/runtime/src/render.ts
index 87b6eb1..3ee35cd 100644
--- a/packages/runtime/src/render.ts
+++ b/packages/runtime/src/render.ts
@@ -193,6 +193,24 @@ function isErrorInput(value: unknown): value is Error {
return value instanceof Error || Object.prototype.toString.call(value) === "[object Error]";
}
+type MaybeWritableStream = NodeJS.WriteStream & {
+ writable?: boolean;
+ writableEnded?: boolean;
+ destroyed?: boolean;
+ writableLength?: number;
+ _writableState?: unknown;
+};
+
+function getWritableStreamState(stdout: MaybeWritableStream): {
+ canWriteToStdout: boolean;
+ hasWritableState: boolean;
+} {
+ return {
+ canWriteToStdout: !stdout.destroyed && !stdout.writableEnded && (stdout.writable ?? true),
+ hasWritableState: stdout._writableState !== undefined || stdout.writableLength !== undefined,
+ };
+}
+
export function createApp(root: Component, rootProps?: RootProps | null): TuiApp {
// exit promise — created at createApp time so waitUntilExit() works even
// before mount (it just hangs until mount + exit).
@@ -268,9 +286,8 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
}
return;
}
- const stdout = mountedAppContext?.stdout ?? process.stdout;
- const canWrite = stdout && !stdout.destroyed && !(stdout as any).writableEnded;
- const hasWritableState = (stdout as any)._writableState !== undefined;
+ const stdout = (mountedAppContext?.stdout ?? process.stdout) as MaybeWritableStream;
+ const { canWriteToStdout, hasWritableState } = getWritableStreamState(stdout);
const finish = () => {
if (isErrorInput(pendingExitError)) {
@@ -280,7 +297,7 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
}
};
- if (canWrite && hasWritableState) {
+ if (canWriteToStdout && hasWritableState) {
stdout.write("", () => finish());
} else {
setImmediate(() => finish());
@@ -288,7 +305,7 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
}
function writeBestEffort(stream: NodeJS.WriteStream, data: string, sync = false) {
- if (stream.destroyed || stream.writableEnded) return;
+ if (!getWritableStreamState(stream as MaybeWritableStream).canWriteToStdout) return;
try {
if (sync) {
// Signal-exit path (G18, Finding A): signal-exit re-raises the signal
@@ -353,7 +370,9 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
// Prevent post-unmount app.clear() from writing to a torn-down stream.
mountedClear = null;
const stdout = mountedAppContext?.stdout;
- const stdoutWritable = stdout && !stdout.destroyed && !stdout.writableEnded;
+ const stdoutWritable = stdout
+ ? getWritableStreamState(stdout as MaybeWritableStream).canWriteToStdout
+ : false;
// Final-frame re-emit at unmount. Ink's settleThrottle path (ink.tsx:749-762)
// runs a final onRender when shouldRenderFinalFrame is true; for the DEBUG
// path throttledOnRender is undefined, so `!this.throttledOnRender` makes
@@ -407,7 +426,8 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
}
}
}
- if (mountedWriter && !mountedDebug && mountedInteractive) mountedWriter.done();
+ if (mountedWriter && !mountedDebug && mountedInteractive && stdoutWritable)
+ mountedWriter.done();
if (mountedAlternateScreen && mountedAppContext) {
writeBestEffort(mountedAppContext.stdout, ansiEscapes.exitAlternativeScreen, sync);
writeBestEffort(mountedAppContext.stdout, "\x1b[?25h", sync);
@@ -1266,18 +1286,28 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
// SAME implementation via useApp().waitUntilRenderFlush — both the
// TuiApp handle and the in-tree composable resolve identically.
async function waitUntilRenderFlush(): Promise {
+ const stream = (mountedAppContext?.stdout ?? process.stdout) as MaybeWritableStream;
+ const { canWriteToStdout, hasWritableState } = getWritableStreamState(stream);
+
// Flush any pending OR scheduled render. Gating on hasPending() alone
// misses the window after schedule() queues a commit but before the
// post-flush callback sets hasPendingFlag, letting this resolve early.
- // flush() resolves immediately when nothing is scheduled or pending, so
- // delegating unconditionally is safe and closes that window.
+ // When stdout cannot be written, match Ink's settleThrottle behavior:
+ // cancel instead of flushing so delayed callbacks cannot write later.
if (mountedScheduler) {
- await mountedScheduler.flush();
+ if (canWriteToStdout) {
+ await mountedScheduler.flush();
+ } else {
+ mountedScheduler.cancel();
+ }
}
// Wait for stdout write barrier — ensures the written frame is
// flushed to the underlying stream.
- const stream = mountedAppContext?.stdout ?? process.stdout;
await new Promise((resolve) => {
+ if (!canWriteToStdout || !hasWritableState) {
+ setImmediate(resolve);
+ return;
+ }
// PassThrough (test fakes) may not support the write callback form;
// fall back to setImmediate so we still yield the event loop.
try {