fix(runtime): flush the deferred trailing commit on non-interactive teardown (Ink parity) (#192)

In non-interactive non-debug mode commits are throttled (renderThrottleMs =
ceil(1000/30) = 34ms). teardown() cancel()s the scheduler, which DISCARDS any
pending trailing-edge commit, and the final-commit gate excluded non-interactive
non-debug — so the non-interactive trailing write emitted frameState.lastOutput
(the last commit that actually ran), a STALE frame. A reactive change deferred to
the trailing edge whose app unmounts within the throttle window was lost on
piped/CI output.

Mirror Ink's settleThrottle: broaden the final-commit gate to run mountedCommit()
in every mode before the trailing write. The non-interactive commit() branch only
refreshes frameState.lastOutput/lastOutputToRender to the current tree and writes
write-once <Static> (it DEFERS the dynamic frame), so the refresh feeds the latest
frame into the trailing write without double-writing it. Verified against real Ink
v7.0.4: the same deferred-then-unmount scenario emits "C\n" (latest); vue-tui now
matches (was "A\n").

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-06-15 02:18:07 +08:00
committed by GitHub
parent 0431870bb2
commit 871f348f2f
2 changed files with 97 additions and 11 deletions
+32 -11
View File
@@ -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 <Static>; 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 {