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 {