Skip to content

Commit e767c28

Browse files
committed
Fix: Accept capture streams in RenderOptions without a type assertion
1 parent 70af033 commit e767c28

5 files changed

Lines changed: 157 additions & 41 deletions

File tree

readme.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2593,23 +2593,27 @@ Type: `ReactNode`
25932593
25942594
Type: `object`
25952595
2596+
The streams don't have to be terminal streams. `stdout` and `stderr` only need a `write()` method, so a stream that captures output in memory works, and the exported `InkOutputStream` and `InkInputStream` types describe those shapes. Interactive rendering and [`useWindowSize()`](#usewindowsize) also call `on()` and `off()` on `stdout`, and handling input needs the raw mode methods on `stdin`, so see those types for the full contract.
2597+
2598+
`RenderOptions` itself defaults to Node's stream types, which is what [`useStdout()`](#usestdout), [`useStdin()`](#usestdin) and [`useStderr()`](#usestderr) keep returning. Annotate with `RenderOptions<InkOutputStream, InkInputStream>` when you store these options or wrap `render()` yourself.
2599+
25962600
###### stdout
25972601
2598-
Type: `stream.Writable`\
2602+
Type: `InkOutputStream`\
25992603
Default: `process.stdout`
26002604
26012605
Output stream where the app will be rendered.
26022606
26032607
###### stdin
26042608
2605-
Type: `stream.Readable`\
2609+
Type: `InkInputStream`\
26062610
Default: `process.stdin`
26072611
26082612
Input stream where app will listen for input.
26092613
26102614
###### stderr
26112615
2612-
Type: `stream.Writable`\
2616+
Type: `InkOutputStream`\
26132617
Default: `process.stderr`
26142618
26152619
Error stream.

src/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
export type {RenderOptions, Instance} from './render.js';
1+
export type {
2+
RenderOptions,
3+
Instance,
4+
InkOutputStream,
5+
InkInputStream,
6+
} from './render.js';
27
export {default as render} from './render.js';
38
export type {RenderToStringOptions} from './render-to-string.js';
49
export {default as renderToString} from './render-to-string.js';

src/render.ts

Lines changed: 58 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,69 @@
1-
import {Stream} from 'node:stream';
1+
import {Stream, type Writable} from 'node:stream';
22
import process from 'node:process';
33
import type {ReactNode} from 'react';
44
import Ink, {type Options as InkOptions, type RenderMetrics} from './ink.js';
55
import instances from './instances.js';
66
import {type KittyKeyboardOptions} from './kitty-keyboard.js';
77

8-
export type RenderOptions = {
8+
/**
9+
Output stream that Ink can render to, like `process.stdout` or a stream that captures output in memory.
10+
11+
Only `write()` is always used. Interactive rendering and `useWindowSize()` also call `on()` and `off()`, and a stream that reports `writableLength` is expected to call the callback that Ink passes to `write()`, otherwise `waitUntilExit()` never settles.
12+
*/
13+
export type InkOutputStream = {
14+
columns?: number;
15+
rows?: number;
16+
isTTY?: boolean;
17+
destroyed?: boolean;
18+
writable?: boolean;
19+
writableEnded?: boolean;
20+
writableLength?: number;
21+
write(data: string, ...rest: unknown[]): unknown;
22+
on?(event: unknown, listener: unknown): unknown;
23+
off?(event: unknown, listener: unknown): unknown;
24+
};
25+
26+
/**
27+
Input stream that Ink can listen for input on, like `process.stdin`.
28+
29+
Every member is optional, because Ink only reads input when `isTTY` is set and a component asks for it. Handling input then calls `addListener()`, `read()`, `setRawMode()`, `setEncoding()`, `ref()` and `unref()`.
30+
*/
31+
export type InkInputStream = {
32+
isTTY?: boolean;
33+
on?(event: unknown, listener: unknown): unknown;
34+
read?(...args: unknown[]): unknown;
35+
setRawMode?(mode: boolean): unknown;
36+
setEncoding?(...args: unknown[]): unknown;
37+
unshift?(...args: unknown[]): unknown;
38+
addListener?(event: unknown, listener: unknown): unknown;
39+
removeListener?(event: unknown, listener: unknown): unknown;
40+
ref?(): unknown;
41+
unref?(): unknown;
42+
};
43+
44+
export type RenderOptions<
45+
OutputStream extends InkOutputStream = NodeJS.WriteStream,
46+
InputStream extends InkInputStream = NodeJS.ReadStream,
47+
> = {
948
/**
1049
Output stream where the app will be rendered.
1150
1251
@default process.stdout
1352
*/
14-
stdout?: NodeJS.WriteStream;
53+
stdout?: OutputStream;
1554

1655
/**
1756
Input stream where app will listen for input.
1857
1958
@default process.stdin
2059
*/
21-
stdin?: NodeJS.ReadStream;
60+
stdin?: InputStream;
2261

2362
/**
2463
Error stream.
2564
@default process.stderr
2665
*/
27-
stderr?: NodeJS.WriteStream;
66+
stderr?: OutputStream;
2867

2968
/**
3069
If true, each update will be rendered as separate output, without replacing the previous one.
@@ -198,20 +237,23 @@ Mount a component and render the output.
198237
*/
199238
const render = (
200239
node: ReactNode,
201-
options?: NodeJS.WriteStream | RenderOptions,
240+
options?: Writable | RenderOptions<InkOutputStream, InkInputStream>,
202241
): Instance => {
242+
const {stdout, stdin, stderr, ...restOptions} = getOptions(options);
243+
203244
const inkOptions: InkOptions = {
204-
stdout: process.stdout,
205-
stdin: process.stdin,
206-
stderr: process.stderr,
207245
debug: false,
208246
exitOnCtrlC: true,
209247
patchConsole: true,
210248
maxFps: 30,
211249
incrementalRendering: false,
212250
concurrent: false,
213251
alternateScreen: false,
214-
...getOptions(options),
252+
...restOptions,
253+
// Ink internally uses Node's stream types, but only the members declared above.
254+
stdout: (stdout ?? process.stdout) as NodeJS.WriteStream,
255+
stdin: (stdin ?? process.stdin) as NodeJS.ReadStream,
256+
stderr: (stderr ?? process.stderr) as NodeJS.WriteStream,
215257
};
216258

217259
const instance: Ink = getInstance(
@@ -237,16 +279,17 @@ const render = (
237279
export default render;
238280

239281
const getOptions = (
240-
stdout: NodeJS.WriteStream | RenderOptions | undefined = {},
241-
): RenderOptions => {
242-
if (stdout instanceof Stream) {
282+
options:
283+
Writable | RenderOptions<InkOutputStream, InkInputStream> | undefined = {},
284+
): RenderOptions<InkOutputStream, InkInputStream> => {
285+
if (options instanceof Stream) {
243286
return {
244-
stdout,
287+
stdout: options,
245288
stdin: process.stdin,
246289
};
247290
}
248291

249-
return stdout;
292+
return options ?? {};
250293
};
251294

252295
const getInstance = (

test/components.tsx

Lines changed: 6 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -773,8 +773,7 @@ test('disable raw mode when all input components are unmounted', async t => {
773773

774774
const {rerender} = render(
775775
<Test renderFirstInput renderSecondInput />,
776-
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
777-
options as any,
776+
options,
778777
);
779778

780779
t.true(stdin.setRawMode.calledOnce);
@@ -829,11 +828,7 @@ test('do not disable raw mode when swapping components that use useInput', async
829828
return step === 1 ? <StepA /> : <StepB />;
830829
}
831830

832-
const {rerender} = render(
833-
<Test step={1} />,
834-
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
835-
options as any,
836-
);
831+
const {rerender} = render(<Test step={1} />, options);
837832

838833
t.true(stdin.setRawMode.calledOnce);
839834
t.true(stdin.ref.calledOnce);
@@ -887,11 +882,7 @@ test('clear pending input parser state when swapping components that use useInpu
887882
return step === 1 ? <StepA /> : <StepB />;
888883
}
889884

890-
const {rerender} = render(
891-
<Test step={1} />,
892-
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
893-
options as any,
894-
);
885+
const {rerender} = render(<Test step={1} />, options);
895886

896887
emitReadable(stdin, '\u001B[');
897888
rerender(<Test step={2} />);
@@ -942,11 +933,7 @@ test('re-ref stdin when input is used after previous unmount', t => {
942933
const onSecondMountInput = spy();
943934

944935
// First render
945-
const {unmount} = render(
946-
<Test onInput={onFirstMountInput} />,
947-
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
948-
options as any,
949-
);
936+
const {unmount} = render(<Test onInput={onFirstMountInput} />, options);
950937

951938
t.true(stdin.ref.calledOnce);
952939
t.true(stdin.setRawMode.calledOnce);
@@ -965,8 +952,7 @@ test('re-ref stdin when input is used after previous unmount', t => {
965952
// Second render with new Ink instance reusing the same stdin
966953
const {unmount: unmount2} = render(
967954
<Test onInput={onSecondMountInput} />,
968-
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
969-
options as any,
955+
options,
970956
);
971957

972958
t.true(stdin.ref.calledTwice);
@@ -1084,8 +1070,7 @@ test('render different component based on whether stdin is a TTY or not', t => {
10841070

10851071
const {rerender} = render(
10861072
<Test renderFirstInput renderSecondInput />,
1087-
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
1088-
options as any,
1073+
options,
10891074
);
10901075

10911076
t.false(stdin.setRawMode.called);

test/render.tsx

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,17 @@ import ansiEscapes from 'ansi-escapes';
1919
import stripAnsi from 'strip-ansi';
2020
import boxen from 'boxen';
2121
import delay from 'delay';
22-
import {render, Box, Text, useApp, useCursor, useInput} from '../src/index.js';
22+
import {
23+
render,
24+
Box,
25+
Text,
26+
useApp,
27+
useCursor,
28+
useInput,
29+
type RenderOptions,
30+
type InkOutputStream,
31+
type InkInputStream,
32+
} from '../src/index.js';
2333
import {type RenderMetrics} from '../src/ink.js';
2434
import {bsu, esu} from '../src/write-synchronized.js';
2535
import {createStdin, emitReadable} from './helpers/create-stdin.js';
@@ -2111,3 +2121,72 @@ test.serial('bsu/esu wraps throttledLog trailing call', t => {
21112121
}
21122122
});
21132123
});
2124+
2125+
const createCaptureStream = () => {
2126+
const writes: string[] = [];
2127+
2128+
return {
2129+
columns: 100,
2130+
rows: 10,
2131+
// eslint-disable-next-line @typescript-eslint/naming-convention
2132+
isTTY: false,
2133+
write(data: string) {
2134+
writes.push(data);
2135+
},
2136+
output: () => writes.join(''),
2137+
};
2138+
};
2139+
2140+
test.serial('accept Node streams in render options', t => {
2141+
const options: RenderOptions = {
2142+
stdout: process.stdout,
2143+
stdin: process.stdin,
2144+
stderr: process.stderr,
2145+
};
2146+
2147+
t.is(options.stdout, process.stdout);
2148+
t.is(options.stdin, process.stdin);
2149+
t.is(options.stderr, process.stderr);
2150+
});
2151+
2152+
test.serial('render to a stream that only implements what Ink uses', t => {
2153+
const stdout = createCaptureStream();
2154+
const stderr = createCaptureStream();
2155+
const options: RenderOptions<InkOutputStream, InkInputStream> = {
2156+
stdout,
2157+
stderr,
2158+
stdin: {},
2159+
};
2160+
2161+
const {unmount} = render(<Text>Hello</Text>, options);
2162+
unmount();
2163+
2164+
t.true(stdout.output().includes('Hello'));
2165+
});
2166+
2167+
test.serial('render to a stream in debug mode', t => {
2168+
const stdout = createCaptureStream();
2169+
2170+
const {unmount} = render(<Text>Hello</Text>, {stdout, debug: true});
2171+
t.true(stdout.output().includes('Hello'));
2172+
2173+
unmount();
2174+
});
2175+
2176+
test.serial(
2177+
'render to a Node stream passed as the second argument',
2178+
async t => {
2179+
const stdout = new PassThrough();
2180+
let output = '';
2181+
2182+
stdout.on('data', (chunk: Uint8Array) => {
2183+
output += textDecoder.decode(chunk);
2184+
});
2185+
2186+
const {unmount} = render(<Text>Hello</Text>, stdout);
2187+
unmount();
2188+
await delay(0);
2189+
2190+
t.true(output.includes('Hello'));
2191+
},
2192+
);

0 commit comments

Comments
 (0)