diff --git a/.agents/docs/parity-ledger.md b/.agents/docs/parity-ledger.md index 2a06ac0..814dd72 100644 --- a/.agents/docs/parity-ledger.md +++ b/.agents/docs/parity-ledger.md @@ -53,8 +53,8 @@ Non-obvious calls made while fixing gaps, recorded for review in the final repor | G16 | box-layout-border | Per-edge borderDimColor=false cannot override general borderDimColor (`\|\| dimAll` vs Ink's `??`) | P3 | merged | `fix/parity-border-dim` | #44 | | G17 | render-lifecycle-reconciler | Screen-reader live-path edges: still grid-painted (Ink linearizes, skipStaticElements:false) + empty SR frame gets a trailing newline (Ink writes wrapped output directly) | P3 | merged | `fix/parity-sr-edges` | #45 | | G18 | render-lifecycle-reconciler | No signal-based teardown — terminal corrupted on SIGINT/SIGTERM/SIGHUP (Ink signal-exit at mount) | P1 | merged | `fix/parity-signal-teardown` | #47 | -| G19 | box-layout-border | Dynamic removal of most yoga style props does not reset to default (stale layout) | P2 | pr-open | `fix/parity-yoga-reset` | #48 | -| G20 | stdout-stderr-stdin-size-cursor | writeToStdout/writeToStderr lack an isUnmounted/teardown guard (post-teardown writes corrupt terminal) | P2 | todo | — | — | +| G19 | box-layout-border | Dynamic removal of most yoga style props does not reset to default (stale layout) | P2 | merged | `fix/parity-yoga-reset` | #48 | +| G20 | stdout-stderr-stdin-size-cursor | writeToStdout/writeToStderr lack an isUnmounted/teardown guard (post-teardown writes corrupt terminal) | P2 | pr-open | `fix/parity-write-after-unmount` | #49 | | G21 | text-wrap-transform | Nested in gets hardcoded index 0 vs child sibling position (squash path) | P3 | todo | — | — | | G22 | app-exit-instances-animation-sr | SR role dedup inherits grandparent role; Ink dedups only vs immediate parent | P3 | todo | — | — | | G23 | app-exit-instances-animation-sr | under SR-joins children with newline; Ink concatenates | P3 | todo | — | — | diff --git a/packages/runtime-tests/integration/composables/write-after-unmount.test.tsx b/packages/runtime-tests/integration/composables/write-after-unmount.test.tsx new file mode 100644 index 0000000..6a63696 --- /dev/null +++ b/packages/runtime-tests/integration/composables/write-after-unmount.test.tsx @@ -0,0 +1,116 @@ +/** + * G20 — writeToStdout/writeToStderr must no-op after teardown (Ink parity). + * + * Ink ink.tsx:673/702 returns early when the instance is unmounted so that a + * write after teardown cannot run clear()/write/restore on an already-torn-down + * renderer and corrupt the terminal. vue-tui must mirror that guard. + */ +import { PassThrough } from "node:stream"; +import { defineComponent } from "vue"; +import { expect, test } from "vite-plus/test"; +import { createApp, Text, useStdout, useStderr } 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 any).ref = () => {}; + (s as any).unref = () => {}; + return s; +} + +test("writeToStdout: pre-unmount write works, post-unmount write is suppressed", async () => { + const stdout = makeTtyStream(); + const stderr = makeTtyStream(); + const stdin = makeFakeStdin(); + + let writeRef: ((data: string) => void) | undefined; + + const App = defineComponent(() => { + const { write } = useStdout(); + writeRef = write; + return () => frame; + }); + + const app = createApp(App); + app.mount({ stdout, stdin, stderr, debug: false, exitOnCtrlC: false }); + + // Wait for initial render to settle + await new Promise((r) => setTimeout(r, 60)); + + // Control: a write BEFORE unmount should produce output + stdout.chunks.length = 0; + writeRef!("before-unmount\n"); + const beforeChunks = stdout.chunks.join(""); + expect(beforeChunks, "pre-unmount write must produce output").toContain("before-unmount"); + + // Now unmount (triggers teardown) + app.unmount(); + + // Clear captured chunks and attempt a write AFTER unmount + stdout.chunks.length = 0; + writeRef!("after-unmount\n"); + const afterChunks = stdout.chunks.join(""); + + // The guard must prevent any write to stdout after teardown + expect( + afterChunks, + `post-unmount write must be suppressed; got: ${JSON.stringify(afterChunks)}`, + ).not.toContain("after-unmount"); +}); + +test("writeToStderr: pre-unmount write works, post-unmount write is suppressed", async () => { + const stdout = makeTtyStream(); + const stderr = makeTtyStream(); + const stdin = makeFakeStdin(); + + let writeRef: ((data: string) => void) | undefined; + + const App = defineComponent(() => { + const { write } = useStderr(); + writeRef = write; + return () => frame; + }); + + const app = createApp(App); + app.mount({ stdout, stdin, stderr, debug: false, exitOnCtrlC: false }); + + // Wait for initial render to settle + await new Promise((r) => setTimeout(r, 60)); + + // Control: a write BEFORE unmount should produce output on stderr + stderr.chunks.length = 0; + writeRef!("before-unmount-err\n"); + const beforeChunks = stderr.chunks.join(""); + expect(beforeChunks, "pre-unmount stderr write must produce output").toContain( + "before-unmount-err", + ); + + // Now unmount (triggers teardown) + app.unmount(); + + // Clear captured chunks and attempt a write AFTER unmount + stderr.chunks.length = 0; + writeRef!("after-unmount-err\n"); + const afterChunks = stderr.chunks.join(""); + + // The guard must prevent any write to stderr after teardown + expect( + afterChunks, + `post-unmount stderr write must be suppressed; got: ${JSON.stringify(afterChunks)}`, + ).not.toContain("after-unmount-err"); +}); diff --git a/packages/runtime/src/render.ts b/packages/runtime/src/render.ts index b91ca5b..13d1996 100644 --- a/packages/runtime/src/render.ts +++ b/packages/runtime/src/render.ts @@ -481,6 +481,10 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp } function writeToStdout(data: string) { + // Mirror Ink ink.tsx:673: return early after teardown so a late write + // (e.g. a stray useStdout().write after unmount) cannot run + // clear()/write/restore on an already-torn-down renderer. + if (teardownStarted) return; if (debug) { stdout.write(data + frameState.fullStaticOutput + frameState.lastOutput); return; @@ -500,6 +504,9 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp } function writeToStderr(data: string) { + // Mirror Ink ink.tsx:702: return early after teardown so a late write + // cannot corrupt the restored terminal state. + if (teardownStarted) return; if (debug) { stderr.write(data); stdout.write(frameState.fullStaticOutput + frameState.lastOutput);