fix(runtime): setRawMode(false) throws on unsupported stdin (Ink parity) (#122)

useStdin().setRawMode(false) on a non-TTY stdin silently no-opped while
setRawMode(true) threw — an asymmetry. Ink's handleSetRawMode throws before
the enable/disable split (App.tsx:315), so both directions throw on an
unsupported stdin, and its test asserts both mount-enable and unmount-disable
throw without ever calling stdin.setRawMode.

Move the isRawModeSupported guard into the public setRawMode wrapper (via a
shared throwRawModeUnsupported helper reusing the existing messages) so both
true/false throw. Internal acquireRawMode/releaseRawMode are unchanged —
composables (useInput/useFocus/usePaste) call those directly, so teardown
release stays a no-op and an unsupported-stdin app still unmounts cleanly.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-06-02 01:04:43 +08:00
committed by GitHub
parent 27307910a2
commit 7ace8f735a
2 changed files with 103 additions and 18 deletions
@@ -1,16 +1,22 @@
import { PassThrough } from "node:stream";
import { nextTick, defineComponent } from "vue";
import { nextTick, defineComponent, onMounted, onUnmounted } from "vue";
import { expect, test } from "vite-plus/test";
import { createApp, Text, useFocus, useInput } from "@vue-tui/runtime";
import { createApp, Text, useFocus, useInput, useStdin } from "@vue-tui/runtime";
import { makeFakeWritable } from "../lifecycle/test-streams.ts";
// Builds a stdin that is NOT a TTY, so isRawModeSupported is false. This mirrors
// piping input into a program (e.g. `echo x | node app.js`) where raw mode can't
// be enabled. Matches Ink's isRawModeSupported = stdin.isTTY check.
function makeNonTtyStdin(): NodeJS.ReadStream {
// be enabled. Matches Ink's isRawModeSupported = stdin.isTTY check. The optional
// setRawMode spy lets a test assert the underlying ioctl is NEVER issued on an
// unsupported stdin (parity with Ink's test/components.tsx setRawMode-throw test).
function makeNonTtyStdin(setRawModeCalls?: boolean[]): NodeJS.ReadStream {
const s = new PassThrough() as unknown as NodeJS.ReadStream;
Object.assign(s, {
isTTY: false,
setRawMode(this: NodeJS.ReadStream, mode: boolean) {
setRawModeCalls?.push(mode);
return this;
},
setEncoding(this: NodeJS.ReadStream) {
return this;
},
@@ -87,3 +93,66 @@ test("useFocus on a non-TTY stdin does not throw (graceful no-op)", async () =>
expect(error).toBeUndefined();
});
// Test C: the PUBLIC useStdin().setRawMode must be SYMMETRIC on a non-TTY stdin —
// BOTH setRawMode(true) (enable) AND setRawMode(false) (disable) throw the same
// descriptive error, and the underlying stdin.setRawMode ioctl is never issued.
// Mirrors Ink's test/components.tsx "setRawMode() should throw if raw mode is not
// supported" (asserts didCatchInMount === 1 AND didCatchInUnmount === 1 AND
// !stdin.setRawMode.called) and Ink's handleSetRawMode (App.tsx:317-327), which
// guards at the TOP, before the enable/disable split. Before the fix vue threw on
// the enable branch (acquireRawMode) but silently no-opped the disable branch
// (releaseRawMode's `if (!isRawModeSupported) return`) — an asymmetry Ink lacks.
test("useStdin().setRawMode is symmetric on a non-TTY: both enable AND disable throw", async () => {
const setRawModeCalls: boolean[] = [];
const enableErrors: Error[] = [];
const disableErrors: Error[] = [];
const App = defineComponent(() => {
const { setRawMode } = useStdin();
onMounted(() => {
try {
setRawMode(true);
} catch (e) {
enableErrors.push(e as Error);
}
});
onUnmounted(() => {
try {
setRawMode(false);
} catch (e) {
disableErrors.push(e as Error);
}
});
return () => <Text>test</Text>;
});
const stdout = makeFakeWritable();
const stdin = makeNonTtyStdin(setRawModeCalls);
const app = createApp(App);
app.waitUntilExit().catch(() => {});
app.mount({ stdout, stdin, debug: true, exitOnCtrlC: false });
await nextTick();
app.unmount();
await nextTick();
const expectedMessage =
"Raw mode is not supported on the stdin provided to Vue TUI.\n" +
"Read about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported";
// Enable path throws (this already held before the fix).
expect(enableErrors).toHaveLength(1);
expect(enableErrors[0]?.message).toBe(expectedMessage);
// Disable path throws too (this is the fix: pre-fix it silently no-opped).
expect(disableErrors).toHaveLength(1);
expect(disableErrors[0]?.message).toBe(expectedMessage);
// The underlying terminal ioctl is never issued — both throws short-circuit
// before touching stdin.setRawMode (Ink: t.false(stdin.setRawMode.called)).
expect(setRawModeCalls).toEqual([]);
});
+30 -14
View File
@@ -1511,9 +1511,35 @@ function createStdinController(
// escape would survive into the next composable.
let lifetimeFloor = 0;
// Match Ink's handleSetRawMode (App.tsx): raw mode on an unsupported stdin
// throws a descriptive error rather than silently no-opping. Two messages —
// one for the default process.stdin, one for a custom stream — both pointing
// at the isRawModeSupported docs.
const throwRawModeUnsupported = (): never => {
if (stdin === process.stdin) {
throw new Error(
"Raw mode is not supported on the current process.stdin, which Vue TUI uses as input stream by default.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported",
);
}
throw new Error(
"Raw mode is not supported on the stdin provided to Vue TUI.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported",
);
};
const controller: StdinController = {
stdin,
setRawMode(mode: boolean) {
// Guard at the TOP — BEFORE the enable/disable split — so the PUBLIC
// useStdin().setRawMode throws symmetrically on an unsupported stdin,
// matching Ink's handleSetRawMode (App.tsx:317-327): both setRawMode(true)
// and setRawMode(false) throw. The guard lives here (not in the internal
// releaseRawMode) because the framework's own composables — useInput /
// useFocus / usePaste — call acquireRawMode()/releaseRawMode() DIRECTLY at
// teardown, and that internal release MUST stay a no-op so an unsupported-
// stdin app can unmount cleanly. Only this public wrapper enforces the
// symmetric throw. (acquireRawMode also throws on its own, so the enable
// path is unchanged for the unguarded useInput consumer.)
if (!appCtx.isRawModeSupported) throwRawModeUnsupported();
if (mode) {
controller.acquireRawMode();
} else {
@@ -1525,20 +1551,10 @@ function createStdinController(
internal_exitOnCtrlC: opts.exitOnCtrlC,
acquireRawMode() {
if (!appCtx.isRawModeSupported) {
// Match Ink's handleSetRawMode (App.tsx): enabling raw mode on an
// unsupported stdin throws a descriptive error rather than silently
// no-opping. Two messages — one for the default process.stdin, one for
// a custom stream — both pointing at the isRawModeSupported docs. The
// unguarded useInput path surfaces this; useFocus guards before calling
// (see composables/useFocus.ts), so it degrades to a no-op like Ink.
if (stdin === process.stdin) {
throw new Error(
"Raw mode is not supported on the current process.stdin, which Vue TUI uses as input stream by default.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported",
);
}
throw new Error(
"Raw mode is not supported on the stdin provided to Vue TUI.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported",
);
// The unguarded useInput path surfaces this throw directly; useFocus
// guards before calling (see composables/useFocus.ts), so it degrades to
// a no-op like Ink.
throwRawModeUnsupported();
}
const state = getRawModeState(stdin);
if (state.refs === 0) {