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:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user