diff --git a/packages/runtime-tests/integration/composables/raw-mode-lifecycle.test.tsx b/packages/runtime-tests/integration/composables/raw-mode-lifecycle.test.tsx
new file mode 100644
index 0000000..c01345f
--- /dev/null
+++ b/packages/runtime-tests/integration/composables/raw-mode-lifecycle.test.tsx
@@ -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 () => a;
+ });
+ const B = defineComponent(() => {
+ useInput(() => {});
+ return () => b;
+ });
+ const App = defineComponent(() => () => (which.value === "a" ? : ));
+
+ 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 () => a;
+ });
+ const B = defineComponent(() => {
+ useInput((input) => bKeys.push(input));
+ return () => b;
+ });
+ const App = defineComponent(() => () => (which.value === "a" ? : ));
+
+ 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 () => listening;
+ });
+
+ 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);
+});
diff --git a/packages/runtime-tests/integration/focus/programmatic-focus.test.tsx b/packages/runtime-tests/integration/focus/programmatic-focus.test.tsx
index 534503c..902a448 100644
--- a/packages/runtime-tests/integration/focus/programmatic-focus.test.tsx
+++ b/packages/runtime-tests/integration/focus/programmatic-focus.test.tsx
@@ -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 () => (
+
+
+
+
+
+ );
+ });
+
+ 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 () => (
+
+
+
+
+
+ );
+ });
+
+ 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;
+
+ const App = defineComponent(() => {
+ manager = useFocusManager();
+ return () => (
+
+
+
+
+
+ );
+ });
+
+ 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
diff --git a/packages/runtime/src/render.ts b/packages/runtime/src/render.ts
index f452fe3..68b21b8 100644
--- a/packages/runtime/src/render.ts
+++ b/packages/runtime/src/render.ts
@@ -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();
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();