fix(runtime): sync raw-mode disable at teardown + same-tick swap & focus-nav Ink parity (#117)

Three small raw-mode/focus corrections, each aligned to Ink v7.0.4 and
covered by a test (TDD red→green where reachable).

1. Raw mode left ON after a synchronous signal exit (Ctrl+C). The terminal
   raw-mode disable is deferred to a microtask (so it survives same-tick
   component swaps), but on the signal-exit path teardown(true) re-raises the
   signal synchronously without draining microtasks, so the disable never ran
   and the shell stopped echoing after Ctrl+C. dispose() now forces the disable
   SYNCHRONOUSLY when raw mode is no longer owned (state.refs === 0 and either
   this dispose released the last ref or a release left pendingDisable set),
   mirroring Ink's unmount-cleanup guard `rawModeEnabledCount > 0 ||
   pendingDisableRawModeRef.current` (App.tsx:626-631). The disable stays gated
   on the SHARED refcount, so a multi-app teardown can't disable while another
   app still holds raw mode.

2. Same-tick useInput swap re-issued setRawMode(true) + stdin.ref() and leaked
   a libuv ref (the deferred disable bailed on refs>0 and never unref'd). Added
   a pendingDisable flag to RawModeState mirroring Ink's pendingDisableRawModeRef
   (App.tsx:331-344): on re-acquire while a disable is pending, skip
   ref()/setRawMode(true) and cancel the queued disable.

3. focusNext/focusPrevious start-index logic factored into a shared
   startSearchIndex() helper so the two directions stay symmetric. Behavior is
   identical for all reachable states; it additionally folds the (unreachable
   while the activeId invariant holds) "activeId not in list" case into the
   same branch instead of diverging per-direction.

Tests: raw-mode-lifecycle.test.tsx (swap no-op, replacement still receives
input, synchronous teardown disable); programmatic-focus.test.tsx (no-active
first/last targeting + active-focus step/wrap via the manager API).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-06-01 10:29:14 +08:00
committed by GitHub
parent 48558c5af6
commit 835e026336
3 changed files with 348 additions and 18 deletions
@@ -0,0 +1,179 @@
import { PassThrough } from "node:stream";
import { defineComponent, nextTick, shallowRef } from "vue";
import { expect, test } from "vite-plus/test";
import { createApp, Text, useInput } from "@vue-tui/runtime";
import { makeFakeWritable } from "../lifecycle/test-streams.ts";
// A TTY stdin that records every setRawMode argument and tracks ref()/unref()
// balance, so a test can assert the EXACT terminal ioctls issued across a
// component swap or teardown (not just the observable input behavior).
function makeSpyStdin(): {
stream: NodeJS.ReadStream;
setRawModeCalls: boolean[];
refCount: () => number;
} {
const setRawModeCalls: boolean[] = [];
let refs = 0;
const s = new PassThrough() as unknown as NodeJS.ReadStream;
Object.assign(s, {
isTTY: true,
setRawMode(this: NodeJS.ReadStream, mode: boolean) {
setRawModeCalls.push(mode);
return this;
},
setEncoding(this: NodeJS.ReadStream) {
return this;
},
ref() {
refs++;
},
unref() {
refs--;
},
});
return { stream: s, setRawModeCalls, refCount: () => refs };
}
// Drain Vue's render flush AND the microtask queue, so the DEFERRED raw-mode
// disable (queueMicrotask in releaseRawMode) gets a chance to run — the test
// needs to prove it short-circuits, which only shows up after it actually fires.
async function settle() {
await nextTick();
await Promise.resolve();
await Promise.resolve();
}
// Ink parity (App.tsx:331-344, pendingDisableRawModeRef): when a useInput
// component is swapped for another in the SAME tick (v-if picks a different
// child type), Vue unmounts the old (releaseRawMode → refs 0 → defers the
// terminal disable to a microtask) THEN mounts the new (acquireRawMode → refs
// back to 0→1). Raw mode is still physically enabled at that moment, so the
// replacement must NOT re-issue stdin.setRawMode(true) or stdin.ref() — Ink
// skips both via its pending-disable flag and cancels the queued disable.
//
// Before the fix vue re-ran both: a redundant setRawMode(true) ioctl AND a
// second ref() whose matching unref never fired (the deferred disable saw
// refs back > 0 and bailed), leaking the libuv ref. This locks one true call.
test("a same-tick useInput swap does not re-issue setRawMode(true) or leak a ref (Ink parity)", async () => {
const which = shallowRef<"a" | "b">("a");
const A = defineComponent(() => {
useInput(() => {});
return () => <Text>a</Text>;
});
const B = defineComponent(() => {
useInput(() => {});
return () => <Text>b</Text>;
});
const App = defineComponent(() => () => (which.value === "a" ? <A /> : <B />));
const stdout = makeFakeWritable();
const { stream: stdin, setRawModeCalls, refCount } = makeSpyStdin();
const app = createApp(App);
app.mount({ stdout, stdin, debug: true, exitOnCtrlC: false });
await settle();
// Baseline: mounting the first useInput enables raw mode exactly once.
expect(setRawModeCalls).toEqual([true]);
expect(refCount()).toBe(1);
// Swap A → B in a single tick. The deferred disable from A's release and B's
// re-acquire both run before/around the microtask checkpoint.
which.value = "b";
await settle();
// No second setRawMode(true) (raw mode never dropped), no setRawMode(false)
// either, and the ref balance stays at 1 — exactly Ink's behavior.
expect(setRawModeCalls).toEqual([true]);
expect(refCount()).toBe(1);
app.unmount();
await settle();
// Final teardown disables raw mode once and releases the ref.
expect(setRawModeCalls).toEqual([true, false]);
expect(refCount()).toBe(0);
});
// The same-tick swap detaches the old component's "data" listener synchronously
// (clearInputState parity) and the replacement re-attaches its own on re-acquire.
// This guards that re-acquire still wires input: a regression that skipped the
// listener re-attach (over-aggressively treating the swap as a pure no-op) would
// leave the replacement deaf.
test("the replacement useInput after a same-tick swap still receives input", async () => {
const which = shallowRef<"a" | "b">("a");
const aKeys: string[] = [];
const bKeys: string[] = [];
const A = defineComponent(() => {
useInput((input) => aKeys.push(input));
return () => <Text>a</Text>;
});
const B = defineComponent(() => {
useInput((input) => bKeys.push(input));
return () => <Text>b</Text>;
});
const App = defineComponent(() => () => (which.value === "a" ? <A /> : <B />));
const stdout = makeFakeWritable();
const { stream: stdin } = makeSpyStdin();
const app = createApp(App);
app.mount({ stdout, stdin, debug: true, exitOnCtrlC: false });
await settle();
which.value = "b";
await settle();
(stdin as unknown as PassThrough).write("z");
await settle();
expect(bKeys).toEqual(["z"]);
expect(aKeys).toEqual([]); // the unmounted A must not receive it
app.unmount();
});
// Ink parity (App.tsx:618-631): Ink's unmount-cleanup effect disables raw mode
// SYNCHRONOUSLY when `rawModeEnabledCount > 0 || pendingDisableRawModeRef.current`,
// during React's synchronous unmount. vue defers the disable to a microtask (to
// survive same-tick swaps), but teardown must still force it synchronously —
// otherwise the signal-exit path (teardown(true), which re-raises the signal
// synchronously without draining microtasks) leaves the terminal in raw mode:
// after Ctrl+C the shell stops echoing keystrokes.
//
// This asserts the SYNCHRONOUS checkpoint right after unmount(), with NO await,
// because that is exactly what the signal path observes. Before the fix the
// disable sat in the still-queued microtask (dispose() skipped it because Vue's
// unmount had already zeroed this controller's local ref count); setRawModeCalls
// was [true] at this point and only became [true, false] after a drain.
test("teardown disables raw mode synchronously so a signal exit can't leave the terminal raw (Ink parity)", async () => {
const App = defineComponent(() => {
useInput(() => {});
return () => <Text>listening</Text>;
});
const stdout = makeFakeWritable();
const { stream: stdin, setRawModeCalls, refCount } = makeSpyStdin();
const app = createApp(App);
app.mount({ stdout, stdin, debug: true, exitOnCtrlC: false });
await settle();
expect(setRawModeCalls).toEqual([true]);
expect(refCount()).toBe(1);
// Synchronous unmount — do NOT await. Raw mode must already be disabled at this
// exact point, the way a synchronous signal-exit teardown would observe it.
app.unmount();
expect(setRawModeCalls).toEqual([true, false]);
expect(refCount()).toBe(0);
// Draining afterward must not double-disable or over-unref (the queued
// microtask was cancelled, not left to fire a second setRawMode(false)).
await settle();
expect(setRawModeCalls).toEqual([true, false]);
expect(refCount()).toBe(0);
});
@@ -224,6 +224,108 @@ test("manually focus previous component via focusPrevious()", async () => {
expect(lastFrame()).toMatch(/Third ✔/);
});
// Ink parity (App.tsx:455-470 / 472-487): with NO focus yet (activeId null),
// focusNext targets the FIRST focusable and focusPrevious targets the LAST. This
// pins the reachable "no current focus" boundary for BOTH directions so the
// shared start-index logic (which folds the null case and the unreachable
// activeId-not-in-list case into one symmetric branch) cannot regress one
// direction without the other.
test("focusNext() with no active focus targets the first focusable", async () => {
let doFocusNext!: () => void;
const App = defineComponent(() => {
const manager = useFocusManager();
doFocusNext = manager.focusNext;
return () => (
<Box flexDirection="column">
<FocusItem label="First" />
<FocusItem label="Second" />
<FocusItem label="Third" />
</Box>
);
});
const { lastFrame } = await render(App);
// No autoFocus anywhere → nothing is focused at mount.
expect(lastFrame()).not.toMatch(/✔/);
// From "no current focus", focusNext lands on the first focusable.
doFocusNext();
await nextTick();
expect(lastFrame()).toMatch(/First ✔/);
expect(lastFrame()).not.toMatch(/Second ✔|Third ✔/);
});
test("focusPrevious() with no active focus targets the last focusable", async () => {
let doFocusPrevious!: () => void;
const App = defineComponent(() => {
const manager = useFocusManager();
doFocusPrevious = manager.focusPrevious;
return () => (
<Box flexDirection="column">
<FocusItem label="First" />
<FocusItem label="Second" />
<FocusItem label="Third" />
</Box>
);
});
const { lastFrame } = await render(App);
expect(lastFrame()).not.toMatch(/✔/);
// From "no current focus", a backward step wraps to the LAST focusable.
doFocusPrevious();
await nextTick();
expect(lastFrame()).toMatch(/Third ✔/);
expect(lastFrame()).not.toMatch(/First ✔|Second ✔/);
});
// Exercise startSearchIndex's VALID-index path (activeId non-null) via the
// manager API directly: focusPrevious from a middle item steps to its
// predecessor, and from the first item wraps to the last; focusNext from the
// last wraps to the first. (Tab-cycling covers the same code path through the
// keypress handler; this pins it through the programmatic API so a regression in
// the shared start-index helper can't slip through either entry point.)
test("focusPrevious()/focusNext() with an active focus step to the adjacent item and wrap", async () => {
let manager!: ReturnType<typeof useFocusManager>;
const App = defineComponent(() => {
manager = useFocusManager();
return () => (
<Box flexDirection="column">
<FocusItem label="First" autoFocus />
<FocusItem label="Second" />
<FocusItem label="Third" />
</Box>
);
});
const { lastFrame } = await render(App);
// First autoFocuses → activeId is the first item.
expect(lastFrame()).toMatch(/First ✔/);
// focusNext from the first → second.
manager.focusNext();
await nextTick();
expect(lastFrame()).toMatch(/Second ✔/);
// focusPrevious from the middle → back to the first (predecessor, not a wrap).
manager.focusPrevious();
await nextTick();
expect(lastFrame()).toMatch(/First ✔/);
// focusPrevious from the first → wraps to the last.
manager.focusPrevious();
await nextTick();
expect(lastFrame()).toMatch(/Third ✔/);
// focusNext from the last → wraps back to the first.
manager.focusNext();
await nextTick();
expect(lastFrame()).toMatch(/First ✔/);
});
// Ink parity (App.tsx:455-470 focusNext / 472-487 focusPrevious): focusNext is
// `findNextFocusable(...) ?? firstFocusableId` and ALWAYS reassigns activeFocusId.
// When NO focusable is active, both branches are undefined → activeFocusId is
+67 -18
View File
@@ -1219,6 +1219,21 @@ function createFocusController(): FocusContext {
return null;
}
// The start index a directional search begins FROM (it scans from the next slot
// in `direction`). With a valid current focus, that's its index. With no current
// focus we begin just outside the end we're moving away from, so the first
// candidate is the first focusable (forward) or the last (backward) — symmetric
// for both directions. findIndex returning -1 (activeId set but not in the list)
// is treated as "no current": unreachable while the activeId invariant holds
// (setActive only ever stores null or a present id; remove() clears a removed
// active), but folding it in here keeps focusNext/focusPrevious from diverging if
// that invariant is ever broken.
function startSearchIndex(direction: 1 | -1): number {
const i = activeId ? focusables.findIndex((f) => f.id === activeId) : -1;
if (i >= 0) return i;
return direction === 1 ? -1 : focusables.length;
}
const ctx: FocusContext = {
activeId: null,
activeIdRef,
@@ -1246,13 +1261,11 @@ function createFocusController(): FocusContext {
// (e.g. left by focus(id) pinning an isActive=false item), matching Ink.
focusNext() {
if (focusables.length === 0) return;
const idx = activeId ? focusables.findIndex((f) => f.id === activeId) : -1;
setActive(findNextActive(idx, 1));
setActive(findNextActive(startSearchIndex(1), 1));
},
focusPrevious() {
if (focusables.length === 0) return;
const idx = activeId ? focusables.findIndex((f) => f.id === activeId) : focusables.length;
setActive(findNextActive(idx, -1));
setActive(findNextActive(startSearchIndex(-1), -1));
},
focus(id) {
const entry = focusables.find((f) => f.id === id);
@@ -1307,13 +1320,18 @@ interface StdinController extends StdinContext {
interface RawModeState {
refs: number;
// True between a last-release (refs→0) and the microtask that actually disables
// raw mode. A same-tick re-acquire reads this to know raw mode is still
// physically on, so it can skip re-issuing ref()/setRawMode(true) and cancel the
// queued disable — Ink's pendingDisableRawModeRef (App.tsx:335-336,361-368).
pendingDisable: boolean;
}
const rawModeRegistry = new WeakMap<NodeJS.ReadStream, RawModeState>();
function getRawModeState(stdin: NodeJS.ReadStream): RawModeState {
let state = rawModeRegistry.get(stdin);
if (!state) {
state = { refs: 0 };
state = { refs: 0, pendingDisable: false };
rawModeRegistry.set(stdin, state);
}
return state;
@@ -1481,9 +1499,20 @@ function createStdinController(
}
const state = getRawModeState(stdin);
if (state.refs === 0) {
if (typeof stdin.ref === "function") stdin.ref();
// If a same-tick swap left raw mode physically enabled (its disable is
// still queued), don't re-ref or re-toggle — just cancel the pending
// disable. Ink (App.tsx:331-344) skips stdin.ref()/setRawMode(true) here
// when isRawModeAlreadyEnabled; re-issuing them is a redundant ioctl AND
// an unbalanced ref() (the deferred disable would bail on refs>0 and never
// unref). setEncoding('utf8') and the data listener still run: encoding is
// idempotent and the listener was detached synchronously in releaseRawMode.
const alreadyEnabled = state.pendingDisable;
state.pendingDisable = false;
if (!alreadyEnabled) {
if (typeof stdin.ref === "function") stdin.ref();
appCtx.setRawMode(true);
}
if (typeof (stdin as any).setEncoding === "function") (stdin as any).setEncoding("utf8");
appCtx.setRawMode(true);
stdin.on("data", handleData);
}
state.refs++;
@@ -1525,9 +1554,12 @@ function createStdinController(
// 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.
// short-circuits if a replacement re-acquired in the meantime — which it
// signals by clearing pendingDisable (matching Ink's flag, App.tsx:362-365).
state.pendingDisable = true;
queueMicrotask(() => {
if (state.refs > 0) return;
if (!state.pendingDisable) return;
state.pendingDisable = false;
// 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
@@ -1547,16 +1579,33 @@ function createStdinController(
appCtx.stdout.write("\x1b[?2004l");
}
bracketedPasteModeCount = 0;
if (localRefs > 0 && appCtx.isRawModeSupported) {
if (appCtx.isRawModeSupported) {
const state = getRawModeState(stdin);
state.refs = Math.max(0, state.refs - localRefs);
localRefs = 0;
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.)
// Drop this controller's outstanding refs (if Vue's unmount hasn't already
// released them via onScopeDispose → releaseRawMode).
let releasedLastRef = false;
if (localRefs > 0) {
state.refs = Math.max(0, state.refs - localRefs);
localRefs = 0;
releasedLastRef = state.refs === 0;
}
// Force the terminal raw-mode disable SYNCHRONOUSLY when raw mode is no
// longer owned. This covers BOTH teardown orderings:
// (1) dispose() ran while this controller still held refs (above), or
// (2) Vue's unmount already fired releaseRawMode (localRefs is 0) which
// DEFERRED the disable to a microtask — but on the signal-exit path
// (teardown(true) re-raises the signal without draining microtasks)
// that microtask never runs, so the terminal would be left raw and
// the shell stops echoing after Ctrl+C.
// Mirrors Ink's unmount cleanup guard `rawModeEnabledCount > 0 ||
// pendingDisableRawModeRef.current` (App.tsx:626-631). Clearing
// pendingDisable also cancels the queued microtask so it can't double-unref.
if (state.refs === 0 && (releasedLastRef || state.pendingDisable)) {
// Unconditionally setRawMode(false) — Ink's disableRawMode (App.tsx:218-222)
// never restores a captured prior raw state. (A restored prevRaw could be
// the framework's own raw=true snapshotted during a sync swap, which would
// leave the terminal raw on exit.)
state.pendingDisable = false;
appCtx.setRawMode(false);
if (typeof stdin.unref === "function") stdin.unref();
inputParser.reset();