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();