diff --git a/packages/runtime-tests/integration/lifecycle/non-interactive-final-frame.test.tsx b/packages/runtime-tests/integration/lifecycle/non-interactive-final-frame.test.tsx
new file mode 100644
index 0000000..47e7bfe
--- /dev/null
+++ b/packages/runtime-tests/integration/lifecycle/non-interactive-final-frame.test.tsx
@@ -0,0 +1,65 @@
+import { PassThrough } from "node:stream";
+import { defineComponent, nextTick, shallowRef } from "vue";
+import { expect, test } from "vite-plus/test";
+import { createApp, Text } from "@vue-tui/runtime";
+import { makeFakeStdin, makeFakeWritable } from "./test-streams.ts";
+
+// NOTE: this exercises the REAL render throttle (renderThrottleMs = ceil(1000/30)
+// = 34ms at the default maxFps). It deliberately does NOT use fake timers: the
+// repro depends on a trailing-edge commit being genuinely DEFERRED at unmount
+// time, which is a wall-clock property of the scheduler. We never wait out the
+// 34ms window — we set the latest state and unmount synchronously inside it — so
+// the test is timing-robust (it never races the trailing timer).
+
+test("non-interactive teardown flushes a deferred trailing commit into the final frame", async () => {
+ // Bug: in non-interactive non-debug mode the dynamic frame is deferred to the
+ // unmount-time trailing write, which emits frameState.lastOutput — the LAST
+ // commit that actually ran. If a reactive change is deferred to the throttle's
+ // trailing edge and the app unmounts before the ~34ms timer fires, teardown()
+ // cancel()s the scheduler (DISCARDING the pending commit) and writes the STALE
+ // frame. Ink avoids this: unmount() settleThrottle()-FLUSHES the throttled
+ // render (refreshing lastOutput to the current tree) before the final write.
+ const value = shallowRef("A");
+ const App = defineComponent(() => () => {value.value});
+
+ const stdout = makeFakeWritable({ columns: 80 });
+ const stderr = makeFakeWritable({ columns: 80 });
+ const { stream: stdin } = makeFakeStdin();
+
+ // Non-interactive is derived from a non-TTY stdout.
+ (stdout as unknown as { isTTY: boolean }).isTTY = false;
+
+ const chunks: string[] = [];
+ (stdout as unknown as PassThrough).on("data", (chunk: Buffer) => {
+ chunks.push(chunk.toString());
+ });
+
+ const app = createApp(App);
+ // debug is left unset (false): debug forces `unthrottled`, which would set
+ // renderThrottleMs=0 and erase the very throttle this bug needs.
+ app.mount({ stdout, stdin, stderr, exitOnCtrlC: false, interactive: false });
+
+ await nextTick();
+ await nextTick();
+
+ // The mount commit already armed the 34ms throttle window, so subsequent
+ // mutations within it are DEFERRED to the trailing edge — no commit runs for
+ // them and frameState.lastOutput stays at the mount frame ("A"). We mutate
+ // twice (B then C) to model a real deferred-update burst; the host tree ends
+ // at "C" while the last committed frame is still "A".
+ value.value = "B";
+ await nextTick();
+ value.value = "C";
+ await nextTick();
+
+ // Unmount immediately, inside the window, before the trailing timer fires —
+ // so the latest state ("C") is still pending/deferred at teardown.
+ app.unmount();
+ await app.waitUntilExit();
+
+ const output = chunks.join("");
+ // The non-interactive final write must reflect the LATEST tree ("C"), not a
+ // stale last-committed frame. Ink v7.0.4 emits exactly "C\n" in this scenario
+ // (verified against the real package), so assert byte parity.
+ expect(output).toBe("C\n");
+});
diff --git a/packages/runtime/src/render.ts b/packages/runtime/src/render.ts
index 60500b7..703ee2a 100644
--- a/packages/runtime/src/render.ts
+++ b/packages/runtime/src/render.ts
@@ -418,16 +418,36 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
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
- // shouldRenderFinalFrame unconditionally true and Ink re-emits the last frame
- // before the unmount-time trailing write. Mirror that here: both interactive
- // and debug get a final mountedCommit() (debug commits are unthrottled, so a
- // re-commit always re-writes `fullStaticOutput + frame`). Non-interactive-
- // non-debug stays excluded — its frame is deferred to the trailing-write block
- // below, matching Ink's `this.lastOutput + '\n'` branch (ink.tsx:817-818).
- if ((mountedInteractive || mountedDebug) && mountedCommit && stdoutWritable) {
+ // Final commit at unmount, mirroring Ink's settleThrottle path
+ // (ink.tsx:749-762): unmount FLUSHES the throttled render so lastOutput
+ // reflects the CURRENT tree before any teardown write. We just cancel()ed
+ // the scheduler, which DISCARDS a pending trailing-edge commit — so a
+ // reactive change deferred to that trailing edge would be lost. Re-running
+ // commit() here recomputes the frame against the live tree and refreshes
+ // frameState.lastOutput, in EVERY mode:
+ // - interactive: re-emits the last frame via the writer (Ink ink.tsx:756);
+ // - debug: unthrottled, so a re-commit re-writes `fullStaticOutput + frame`
+ // (the debug branch's stdout.write — gated !teardownStarted so it doesn't
+ // push a spurious live frames[] entry);
+ // - non-interactive non-debug: the commit only refreshes frameState
+ // (lastOutput/lastOutputToRender) and writes write-once ; it
+ // DEFERS the dynamic frame to the trailing-write block below. So this
+ // refresh feeds the correct, latest `lastFrame + "\n"` into that write
+ // (matching Ink's `this.lastOutput + '\n'`, ink.tsx:817-818) WITHOUT
+ // double-writing the dynamic frame. Before this, a deferred trailing
+ // change unmounted within the throttle window emitted the STALE
+ // last-committed frame instead of the latest tree.
+ // EXCEPTION — non-interactive non-debug ERROR teardown: do NOT re-commit. A
+ // re-commit would refresh frameState.lastOutput to the ErrorOverview and the
+ // trailing-write block would then emit it, but the non-interactive error
+ // path paints NO overview (only the trailing newline), matching Ink/main
+ // (see error-capture-race.test.tsx). Interactive/debug still commit on error
+ // (the overview IS shown on screen there), so they keep the unconditional path.
+ if (
+ mountedCommit &&
+ stdoutWritable &&
+ (mountedInteractive || mountedDebug || !isErrorInput(pendingExitError))
+ ) {
try {
mountedCommit();
} catch {
@@ -461,7 +481,8 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
// final-frame re-emit above re-wrote the last one), so only a trailing
// newline is owed — Ink writes a bare "\n" here unconditionally. In
// non-debug non-interactive mode the dynamic frame was deferred during
- // rendering, so write it now as `lastFrame + "\n"`.
+ // rendering; the final commit() above refreshed lastOutput to the current
+ // tree, so write that latest frame now as `lastFrame + "\n"`.
if (mountedDebug) {
writeBestEffort(mountedAppContext.stdout, "\n");
} else {