From f99f23e346da5a7b01fb36b9639b0ef61c4a207e Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sun, 31 May 2026 22:04:06 +0800 Subject: [PATCH] fix(runtime): restoreLastOutput falls back on an empty frame, matching Ink (#100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The frame-restore after an external stdout write (console.log / useStdout().write) used `lastOutputToRender ?? lastOutput + "\n"`. `??` only falls back for null/undefined, so an empty-string lastOutputToRender (the initial value, and the value left by the screen-reader empty-frame path) restored "" — nothing — where Ink restores lastOutput + "\n". Ink uses `||` (ink.tsx:507) and so does vue's own mountedClear (render.ts:668); :518 was the lone inconsistent site. Changed `??` to `||`. Locked by an SR-empty-frame + external-write test that re-emits "\n". Co-authored-by: Claude Opus 4.8 (1M context) --- .../restore-last-output-empty.test.tsx | 111 ++++++++++++++++++ packages/runtime/src/render.ts | 8 +- 2 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 packages/runtime-tests/integration/lifecycle/restore-last-output-empty.test.tsx diff --git a/packages/runtime-tests/integration/lifecycle/restore-last-output-empty.test.tsx b/packages/runtime-tests/integration/lifecycle/restore-last-output-empty.test.tsx new file mode 100644 index 0000000..9426b90 --- /dev/null +++ b/packages/runtime-tests/integration/lifecycle/restore-last-output-empty.test.tsx @@ -0,0 +1,111 @@ +/** + * Ink parity: restoreLastOutput must fall back from an EMPTY `lastOutputToRender`. + * + * When an external stdout write (console.log / useStdout().write) happens while a + * frame is on screen, Ink re-emits the active frame afterwards: + * + * this.log(this.lastOutputToRender || this.lastOutput + '\n') // ink.tsx:507 + * + * With `||`, an EMPTY `lastOutputToRender` falls back to `lastOutput + '\n'`; when + * both are empty that is "\n", so Ink still emits one byte. The buggy vue code used + * `??`, which only falls back on null/undefined and let an empty string through — + * restoring NOTHING and diverging from Ink (and from vue's own mountedClear at + * render.ts:668, which already uses `||`). + * + * Reachable scenario exercised here: SCREEN-READER mode with an empty-rendering + * component. In the SR commit branch (render.ts:845+, Ink parity G59) the frame is + * the wrapped SR output with NO appended "\n", so an empty frame leaves + * lastOutputToRender === "" (and the unchanged-empty-frame early-return at + * render.ts:870 never overwrites the "" initial value). When an external write then + * triggers restoreLastOutput, both lastOutputToRender and lastOutput are "", so the + * restore chunk must be "\n" (Ink) rather than "" (buggy ??). + * + * Uses createApp with debug:false + a fake TTY stream so the real interactive + * writeToStdout -> restoreLastOutput path runs. Test config forces CI:"false". + */ +import { PassThrough } from "node:stream"; +import { defineComponent } from "vue"; +import { expect, test } from "vite-plus/test"; +import { createApp, useStdout } from "@vue-tui/runtime"; + +function makeTtyStream(): NodeJS.WriteStream & { chunks: string[] } { + const s = new PassThrough() as unknown as NodeJS.WriteStream & { chunks: string[] }; + Object.assign(s, { columns: 80, rows: 24, isTTY: true, chunks: [] as string[] }); + s.on("data", (chunk: Buffer) => s.chunks.push(chunk.toString())); + return s; +} + +function makeFakeStdin(): NodeJS.ReadStream { + const s = new PassThrough() as unknown as NodeJS.ReadStream; + Object.assign(s, { + isTTY: true, + setRawMode() { + return s; + }, + setEncoding() { + return s; + }, + }); + (s as { ref?: () => void }).ref = () => {}; + (s as { unref?: () => void }).unref = () => {}; + return s; +} + +test("restoreLastOutput re-emits lastOutput+'\\n' when lastOutputToRender is empty (Ink parity ink.tsx:507)", async () => { + const stdout = makeTtyStream(); + const stderr = makeTtyStream(); + const stdin = makeFakeStdin(); + + let writeRef: ((data: string) => void) | undefined; + + // Renders nothing -> empty SR frame -> lastOutputToRender stays "". + const App = defineComponent(() => { + const { write } = useStdout(); + writeRef = write; + return () => null; + }); + + const app = createApp(App); + app.mount({ + stdout, + stdin, + stderr, + debug: false, + exitOnCtrlC: false, + isScreenReaderEnabled: true, + }); + + // Let the initial (empty) SR commit settle. + await new Promise((r) => setTimeout(r, 60)); + + expect(writeRef, "useStdout().write should be available after mount").toBeDefined(); + + // Capture only the writes produced by this external-write call. + stdout.chunks.length = 0; + writeRef!("external-data\n"); + + const output = stdout.chunks.join(""); + + // The external data itself must be present. + expect(output).toContain("external-data"); + + // Everything after the external data is the restore. log-update always emits a + // cursor-hide escape (\x1b[?25l) on its first write; the load-bearing difference + // is whether the restored CONTENT ("\n", from `lastOutput + "\n"`) follows it. + // With the Ink `||` fallback the restore re-emits "\n"; the buggy `??` path passed + // the empty string straight through, so log-update's hasChanges() short-circuited + // and NO content newline was written. Strip the cursor-toggle/sync escapes and + // assert the restored content newline survives. + const afterData = output.slice(output.indexOf("external-data") + "external-data\n".length); + const restoredContent = afterData + .replace("\x1b[?25l", "") // log-update first-render cursor hide + .replace("\x1b[?25h", "") // possible cursor show + .replace("\x1b[?2026l", "") // ESU + .replace("\x1b[?2026h", ""); // BSU (defensive) + expect( + restoredContent, + `restore must re-emit Ink's "\\n" fallback (lastOutput + "\\n"); buggy ?? emitted no content. afterData=${JSON.stringify(afterData)} full=${JSON.stringify(output)}`, + ).toBe("\n"); + + app.unmount(); +}); diff --git a/packages/runtime/src/render.ts b/packages/runtime/src/render.ts index 369e692..7224734 100644 --- a/packages/runtime/src/render.ts +++ b/packages/runtime/src/render.ts @@ -515,7 +515,13 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp // Clear() resets log-update's cursor state, so replay the latest cursor // intent before restoring output after external stdout/stderr writes. writer.setCursorPosition(cursorPosition); - writer.write(frameState.lastOutputToRender ?? frameState.lastOutput + "\n"); + // Use `||` (not `??`): an EMPTY lastOutputToRender — its initial value before + // the first content commit, the value the narrowing-resize path assigns + // (render.ts:1043), and what an empty screen-reader frame leaves — must fall + // back to `lastOutput + "\n"`, matching Ink (ink.tsx:507) and vue's own + // mountedClear (render.ts:668). `??` only falls back for null/undefined, so an + // empty string would pass through and restore nothing after an external write. + writer.write(frameState.lastOutputToRender || frameState.lastOutput + "\n"); } function writeToStdout(data: string) {