diff --git a/packages/runtime-tests/integration/lifecycle/raw-mode-teardown.sequential.test.tsx b/packages/runtime-tests/integration/lifecycle/raw-mode-teardown.sequential.test.tsx new file mode 100644 index 0000000..0883de7 --- /dev/null +++ b/packages/runtime-tests/integration/lifecycle/raw-mode-teardown.sequential.test.tsx @@ -0,0 +1,140 @@ +// Sequential: these tests assert on the raw-mode controller's deferred teardown, +// which is driven by queueMicrotask + a real 20ms pending-escape flush timer. +// File-level parallelism can perturb the microtask/timer interleaving relative to +// other apps mounting/unmounting on the shared process, so we keep them serial. + +import { PassThrough } from "node:stream"; +import { defineComponent, h, nextTick, shallowRef } from "vue"; +import { expect, test } from "vite-plus/test"; +import { createApp, Text, useInput } from "@vue-tui/runtime"; +import { makeFakeWritable } from "./test-streams.ts"; + +// A fake stdin that, like a real PTY, reflects the last setRawMode call in `isRaw` +// AND records every setRawMode call in `history`. The shared test-streams stdin +// does not track `isRaw`, which hides the prevRaw re-capture corruption (FIX B): +// on a real terminal, a deferred setRawMode(false) leaves isRaw=true, so a same- +// tick re-acquire snapshots prevRaw=true. +function makeRawTrackingStdin(): { + stream: NodeJS.ReadStream; + rawMode: { current: boolean; history: boolean[] }; +} { + const rawMode = { current: false, history: [] as boolean[] }; + const s = new PassThrough() as unknown as NodeJS.ReadStream; + Object.assign(s, { + isTTY: true, + isRaw: false, + setRawMode(this: NodeJS.ReadStream, mode: boolean) { + (this as { isRaw: boolean }).isRaw = mode; + rawMode.current = mode; + rawMode.history.push(mode); + return this; + }, + setEncoding(this: NodeJS.ReadStream) { + return this; + }, + }); + (s as { ref?: () => void }).ref = () => {}; + (s as { unref?: () => void }).unref = () => {}; + return { stream: s, rawMode }; +} + +const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +// FIX A (P6): a same-tick useInput swap must not leak buffered parser state into +// the replacement. Ink clears input state synchronously on the last release +// (clearInputState — App.tsx:212-216) and defers only the terminal toggle. +// Port of Ink test/components.tsx:855-909. +test.sequential("swapping useInput components clears pending parser state (no leaked partial escape)", async () => { + const receivedByB: string[] = []; + const showA = shallowRef(true); + + const StepA = defineComponent(() => { + useInput(() => {}); + return () => h(Text, null, () => "A"); + }); + + const StepB = defineComponent(() => { + useInput((input) => { + receivedByB.push(input); + }); + return () => h(Text, null, () => "B"); + }); + + const Root = defineComponent(() => { + return () => (showA.value ? h(StepA, { key: "a" }) : h(StepB, { key: "b" })); + }); + + const stdout = makeFakeWritable(); + const stderr = makeFakeWritable(); + const { stream: stdin } = makeRawTrackingStdin(); + + const app = createApp(Root); + app.mount({ stdout, stdin, stderr, debug: true, exitOnCtrlC: false }); + await nextTick(); + + // Buffer a partial escape sequence (CSI start, no final byte). The parser + // holds "\x1b[" as pending and schedules a 20ms flush timer. + stdin.emit("data", "\x1b["); + + // Swap StepA -> StepB in the same tick: A unmounts (refs -> 0, queues the + // deferred disable), B mounts (refs 0 -> 1, re-attaches the data listener). + showA.value = false; + await nextTick(); + + // Let the queued microtask AND the 20ms pending-escape flush run. + await Promise.resolve(); + await wait(40); + + // Ink: the replacement receives nothing — the stale "\x1b[" must not leak. + expect(receivedByB).toEqual([]); + + app.unmount(); +}); + +// FIX B (P7): after a sync setRawMode(false)->(true) swap then teardown, the +// terminal must be restored (final setRawMode call is false). The previous +// prevRaw-restore would re-capture prevRaw=true while raw mode was still active +// (deferred disable), leaving the terminal in raw mode on exit. Ink unconditionally +// setRawMode(false) on disable (App.tsx:218-222). +test.sequential("final raw-mode teardown restores the terminal (setRawMode(false)) after a sync re-acquire swap", async () => { + const active = shallowRef(true); + + // Two useInput components: dropping one and adding another in the same tick is + // a release(false)+acquire(true) cycle while the deferred disable is pending. + const Listener = defineComponent(() => { + useInput(() => {}); + return () => h(Text, null, () => "x"); + }); + + const Root = defineComponent(() => { + return () => (active.value ? h(Listener, { key: "a" }) : h(Listener, { key: "b" })); + }); + + const stdout = makeFakeWritable(); + const stderr = makeFakeWritable(); + const { stream: stdin, rawMode } = makeRawTrackingStdin(); + + const app = createApp(Root); + app.mount({ stdout, stdin, stderr, debug: true, exitOnCtrlC: false }); + await nextTick(); + + expect(rawMode.current).toBe(true); + + // Sync swap: release -> acquire in the same tick. With the old code, + // acquireRawMode re-captures prevRaw from the still-true isRaw -> prevRaw=true. + active.value = false; + await nextTick(); + await Promise.resolve(); + + // Raw mode must still be on across the swap (the deferred toggle is the whole + // reason for the microtask — never break this). + expect(rawMode.current).toBe(true); + + // Now tear down. The final disable must leave the terminal NOT in raw mode. + app.unmount(); + await Promise.resolve(); + await wait(5); + + expect(rawMode.current).toBe(false); + expect(rawMode.history.at(-1)).toBe(false); +}); diff --git a/packages/runtime/src/render.ts b/packages/runtime/src/render.ts index 7224734..b90ea92 100644 --- a/packages/runtime/src/render.ts +++ b/packages/runtime/src/render.ts @@ -1280,14 +1280,13 @@ interface StdinController extends StdinContext { interface RawModeState { refs: number; - prevRaw: boolean | null; } const rawModeRegistry = new WeakMap(); function getRawModeState(stdin: NodeJS.ReadStream): RawModeState { let state = rawModeRegistry.get(stdin); if (!state) { - state = { refs: 0, prevRaw: null }; + state = { refs: 0 }; rawModeRegistry.set(stdin, state); } return state; @@ -1455,7 +1454,6 @@ function createStdinController( } const state = getRawModeState(stdin); if (state.refs === 0) { - state.prevRaw = (stdin as { isRaw?: boolean }).isRaw ?? false; if (typeof stdin.ref === "function") stdin.ref(); if (typeof (stdin as any).setEncoding === "function") (stdin as any).setEncoding("utf8"); appCtx.setRawMode(true); @@ -1484,18 +1482,32 @@ function createStdinController( const state = getRawModeState(stdin); state.refs = Math.max(0, state.refs - 1); localRefs = Math.max(0, localRefs - 1); - if (state.refs === 0 && state.prevRaw !== null) { - // Defer the actual disable: when components swap (v-if key change), - // Vue unmounts the old before mounting the new, so refs briefly hits 0. - // Disabling synchronously would drop raw mode between the two mounts. + if (state.refs === 0) { + // Stop owning input SYNCHRONOUSLY on the last release, matching Ink's + // clearInputState (App.tsx:212-216,357): reset the parser, cancel the + // pending-escape flush, and detach the data/readable listeners NOW — so a + // partial escape buffered before a same-render useInput swap cannot leak + // into the replacement. (A same-tick re-acquire re-attaches the listener + // with a fresh parser; deferring this is the bug — the gated microtask + // below short-circuits when refs is back >0, so the reset never ran.) + inputParser.reset(); + clearPendingFlush(); + stdin.off("readable", handleReadable); + stdin.off("data", handleData); + // Defer ONLY the terminal raw-mode toggle (Ink defers just disableRawMode, + // App.tsx:359-368): when components swap (v-if/key change), Vue unmounts + // the old before mounting the new, so refs briefly hits 0. Disabling + // synchronously would drop raw mode between the two mounts; the microtask + // short-circuits if a replacement re-acquired in the meantime. queueMicrotask(() => { - if (state.refs > 0 || state.prevRaw === null) return; - appCtx.setRawMode(state.prevRaw); - state.prevRaw = null; - stdin.off("readable", handleReadable); - stdin.off("data", handleData); + if (state.refs > 0) return; + // Unconditionally setRawMode(false) — Ink's disableRawMode (App.tsx:218-222) + // never restores a captured prior raw state. Restoring a captured prevRaw was a + // vue-only invention that corrupts on a sync re-acquire swap: it gets + // re-snapshotted as true (raw still active via the deferred toggle), leaving + // the terminal in raw mode after exit. + appCtx.setRawMode(false); if (typeof stdin.unref === "function") stdin.unref(); - inputParser.reset(); }); } }, @@ -1512,9 +1524,13 @@ function createStdinController( const state = getRawModeState(stdin); state.refs = Math.max(0, state.refs - localRefs); localRefs = 0; - if (state.refs === 0 && state.prevRaw !== null) { - appCtx.setRawMode(state.prevRaw); - state.prevRaw = null; + if (state.refs === 0) { + // Unconditionally setRawMode(false) on final teardown — Ink's + // disableRawMode (App.tsx:218-222) never restores a captured prior raw + // state. (Same rationale as releaseRawMode: a restored prevRaw could be + // the framework's own raw=true snapshotted during a sync swap, which + // would leave the terminal raw on exit.) + appCtx.setRawMode(false); if (typeof stdin.unref === "function") stdin.unref(); inputParser.reset(); }